import { type Dirent, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, } from "node:fs"; import path from "node:path"; import config from "../config.ts"; import { paths, STALE_LOCK_MS, VALID_REPO_NAME_RE } from "../constants.ts"; import type { RepositoryRow } from "../db/index.ts"; import { db } from "../db/index.ts"; import { git, repoPath } from "../services/git.ts"; // Converts a non-bare repo to bare in-place by extracting the .git directory. // Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/ // Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/ async function convertNonBareRepo( entryName: string, entryPath: string, ): Promise { const dotGitPath = path.join(entryPath, ".git"); const hasGitSuffix = entryName.endsWith(".git"); const baseName = hasGitSuffix ? entryName : `${entryName}.git`; const targetPath = path.join(paths.REPOS_DIR, baseName); try { if (hasGitSuffix) { // Case 2: source and target path are the same dir — use a temp location. const tmpPath = path.join( paths.REPOS_DIR, `.${entryName}.bare_tmp`, ); renameSync(dotGitPath, tmpPath); rmSync(entryPath, { recursive: true, force: true }); renameSync(tmpPath, targetPath); } else { // Case 1: simple move. renameSync(dotGitPath, targetPath); rmSync(entryPath, { recursive: true, force: true }); } const worktreesPath = path.join(targetPath, "worktrees"); if (existsSync(worktreesPath)) { rmSync(worktreesPath, { recursive: true, force: true }); } console.log(`Converted non-bare repo to bare: ${baseName}`); } catch (err) { console.error(`Failed to convert non-bare repo ${entryName}:`, err); } } // Scans paths.REPOS_DIR for non-bare repos and converts them before the main sync. async function convertNonBareRepos(): Promise { let entries: Dirent[]; try { entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true }); } catch { return; } const conversions: Promise[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const entryPath = path.join(paths.REPOS_DIR, entry.name); if (existsSync(path.join(entryPath, ".git"))) { conversions.push(convertNonBareRepo(entry.name, entryPath)); } } await Promise.all(conversions); } function clearStaleConfigLocks(): void { let entries: Dirent[]; try { entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory() || !entry.name.endsWith(".git")) continue; const lockPath = path.join(paths.REPOS_DIR, entry.name, "config.lock"); try { if (Date.now() - statSync(lockPath).mtimeMs < STALE_LOCK_MS) continue; unlinkSync(lockPath); console.log(`Removed stale config lock: ${entry.name}`); } catch { // No lock file (the normal case), or it vanished meanwhile. } } } export function listDiskRepoNames(): string[] { try { return readdirSync(paths.REPOS_DIR, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.endsWith(".git")) .map((e) => e.name.slice(0, -4)); } catch { return []; } } export function repoDiskExists(name: string): boolean { return existsSync(repoPath(name)); } export async function ensureRepoRecord(name: string): Promise { const existing = await db .selectFrom("repositories") .selectAll() .where("name", "=", name) .executeTakeFirst(); if (existing) return existing; // First time this repo is seen (pushed externally, manually imported, or // freshly created): make sure git treats it as bare before we record and // serve it. Doing this only on discovery — not on every read — keeps repo // page views free of a per-request subprocess spawn and config write. // // ensureBare never throws. It must not be able to block the insert below: // without a DB record every later request lands here again, so one failed // config write would turn into a permanent per-request failure loop. await git.ensureBare(name); const branch = await git.defaultBranch(name); const now = new Date().toISOString(); return await db .insertInto("repositories") .values({ name, description: null, is_private: config.SCANNED_REPO_PRIVATE ? 1 : 0, default_branch: branch, created_at: now, }) .returningAll() .executeTakeFirstOrThrow(); } async function ensureSigningSetup(): Promise { const hostname = new URL(config.BASE_URL).hostname; const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`; if (!existsSync(paths.SSH_HOST_KEY_PATH)) { const { spawnSync } = await import("node:child_process"); spawnSync( "ssh-keygen", [ "-t", "ed25519", "-N", "", "-f", paths.SSH_HOST_KEY_PATH, "-C", hostname, ], { stdio: "ignore" }, ); console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH); } else { try { const existing = readFileSync(pubKeyPath, "utf8").trim(); const keyHostname = existing.split(/\s+/)[2] ?? ""; if (keyHostname !== hostname) { console.warn( `Warning: SSH host key comment "${keyHostname}" does not match` + ` current hostname "${hostname}". The key was likely generated` + ` for a different BASE_URL. Commit signatures may show an` + ` unexpected identity.`, ); } } catch { // .pub file missing or unreadable — handled below } } let pubKey: string; try { pubKey = readFileSync(pubKeyPath, "utf8").trim(); } catch { console.warn("Could not read SSH public key at", pubKeyPath); return; } let content = `* namespaces="git" ${pubKey}\n`; if (config.EXTRA_ALLOWED_SIGNERS_PATH) { try { const extra = readFileSync( config.EXTRA_ALLOWED_SIGNERS_PATH, "utf8", ); content += extra.endsWith("\n") ? extra : `${extra}\n`; } catch { console.warn( "Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:", config.EXTRA_ALLOWED_SIGNERS_PATH, ); } } await Bun.write(paths.ALLOWED_SIGNERS_PATH, content); } export async function syncStartup(): Promise { await ensureSigningSetup(); clearStaleConfigLocks(); await convertNonBareRepos(); const diskNames = new Set(listDiskRepoNames()); const dbRepos = await db .selectFrom("repositories") .select(["id", "name"]) .execute(); // Ensure all on-disk repos have DB records (e.g. repos pushed externally). // Skip names that fail validation — they can't be served anyway. const validDiskNames = [...diskNames].filter((n) => VALID_REPO_NAME_RE.test(n), ); await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name))); const stale = dbRepos.filter((r) => !diskNames.has(r.name)); if (stale.length > 0) { await db .deleteFrom("repositories") .where( "id", "in", stale.map((r) => r.id), ) .execute(); console.log( `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`, ); } // Release cleanup: sync DB records against on-disk release directories. // Orphaned directories (no DB record) arise when the server crashes after // files are written but before the transaction commits. Stale DB records // (directory missing) arise when the server crashes after rmSync but before // the DB delete during release deletion. // // Note: releases with no source code and no assets have no on-disk // directory, so we only apply the stale-record check to releases that // should have a directory (include_source_code=1 or has release_assets). const allReleaseIds = new Set( (await db.selectFrom("releases").select("id").execute()).map( (r) => r.id, ), ); try { for (const entry of readdirSync(paths.RELEASES_DIR, { withFileTypes: true, })) { if (!entry.isDirectory()) continue; const id = Number(entry.name); if (!Number.isNaN(id) && !allReleaseIds.has(id)) { rmSync(path.join(paths.RELEASES_DIR, entry.name), { recursive: true, force: true, }); console.log( `Removed orphaned release directory: ${entry.name}`, ); } } } catch { // paths.RELEASES_DIR may not exist yet on first run } // Only check for missing dirs on releases that should have one. const releasesWithDirs = await db .selectFrom("releases") .select("releases.id") .where((eb) => eb.or([ eb("releases.include_source_code", "=", 1), eb.exists( eb .selectFrom("release_assets") .select("release_assets.id") .whereRef( "release_assets.release_id", "=", "releases.id", ), ), ]), ) .execute(); const staleReleases = releasesWithDirs.filter( (r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))), ); if (staleReleases.length > 0) { await db .deleteFrom("releases") .where( "id", "in", staleReleases.map((r) => r.id), ) .execute(); console.log( `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`, ); } }