import { execFile } from 'node:child_process'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { promisify } from 'node:util'; import { writeInventories } from './inventory.mjs'; const execFileAsync = promisify(execFile); const BRANCH_PATTERN = /^(?!\/)(?!.*(?:\.\.|\/\/|@\{|\\))[A-Za-z0-9._/-]{1,200}$/; const ZERO_SHA = /^0{40,64}$/; export class DeploymentError extends Error { constructor(message, status = 409) { super(message); this.name = 'DeploymentError'; this.status = status; } } export class GitService { constructor(config, { run = execFileAsync, now = () => new Date() } = {}) { this.config = config; this.run = run; this.now = now; this.queue = Promise.resolve(); this.state = null; } async initialize() { await mkdir(dirname(this.config.statePath), { recursive: true }); this.state = await this.#loadState(); await this.#assertRepository(); await this.#assertClean(); if (!this.config.allowedBranches.includes(this.state.activeBranch)) { throw new Error(`Active branch ${this.state.activeBranch} is not allowed`); } await this.refreshInventory(); } enqueue(operation) { const result = this.queue.then(operation, operation); this.queue = result.catch(() => undefined); return result; } async deployWebhook({ deliveryId, branch, after }) { return this.enqueue(async () => { if (!deliveryId || deliveryId.length > 128) { throw new DeploymentError('Invalid delivery ID', 400); } if (this.state.deliveries.includes(deliveryId)) { return { duplicate: true, ...this.publicStatus() }; } if (branch !== this.state.activeBranch) { return { ignored: true, reason: 'inactive branch', ...this.publicStatus() }; } if (ZERO_SHA.test(after)) { return { ignored: true, reason: 'deleted ref', ...this.publicStatus() }; } const result = await this.#deploy({ branch, expectedCommit: after, allowUnrelated: false }); this.state.deliveries = [...this.state.deliveries.slice(-199), deliveryId]; await this.#saveState(); return result; }); } async deployAdmin({ requestId, branch, expectedCommit }) { return this.enqueue(async () => { if (this.state.adminRequests.includes(requestId)) { return { duplicate: true, ...this.publicStatus() }; } const result = await this.#deploy({ branch, expectedCommit, allowUnrelated: branch !== this.state.activeBranch }); this.state.activeBranch = branch; this.state.adminRequests = [...this.state.adminRequests.slice(-199), requestId]; await this.#saveState(); return result; }); } async deploySync({ requestKey, expectedCommit }) { return this.enqueue(async () => { if (this.state.syncRequests.includes(requestKey)) { return { duplicate: true, ...this.publicStatus() }; } const result = await this.#deploy({ branch: this.state.activeBranch, expectedCommit, allowUnrelated: false, }); this.state.syncRequests = [...this.state.syncRequests.slice(-199), requestKey]; await this.#saveState(); return result; }); } async #deploy({ branch, expectedCommit, allowUnrelated }) { this.#validateBranch(branch); await this.#assertClean(); const remoteRef = `refs/remotes/image-hook/${branch}`; await this.#git('fetch', '--no-tags', '--prune', this.config.remoteUrl, `+refs/heads/${branch}:${remoteRef}`); const target = await this.#gitText('rev-parse', '--verify', `${remoteRef}^{commit}`); if (expectedCommit && target !== expectedCommit) { throw new DeploymentError('Payload commit does not match remote branch tip'); } const current = await this.#gitText('rev-parse', 'HEAD'); if (!allowUnrelated && current !== target) { const ancestor = await this.#gitExit('merge-base', '--is-ancestor', current, target); if (ancestor !== 0) { throw new DeploymentError('Non-fast-forward deployment rejected'); } } if (current !== target) { await this.#git('checkout', '--detach', target); } this.state.lastSuccess = { branch, commit: target, at: this.now().toISOString(), }; this.state.lastError = null; const inventory = await this.refreshInventory(branch, target); await this.#saveState(); return { changed: current !== target, inventoryAssets: inventory.assets.length, ...this.publicStatus() }; } async refreshInventory(branch = this.state.activeBranch, commit) { const resolvedCommit = commit ?? await this.#gitText('rev-parse', 'HEAD'); const output = await this.#gitRaw('ls-files', '-z', '--', 'game', 'icons'); const paths = output.split('\0').filter(Boolean); return writeInventories({ repositoryPath: this.config.repositoryPath, paths, branch, commit: resolvedCommit, publicBases: this.config.publicBases, generatedAt: this.now(), }); } publicStatus() { return { activeBranch: this.state.activeBranch, lastSuccess: this.state.lastSuccess, lastError: this.state.lastError, }; } async recordError(error) { this.state.lastError = { message: error.message, at: this.now().toISOString() }; await this.#saveState(); } #validateBranch(branch) { if (!BRANCH_PATTERN.test(branch) || !this.config.allowedBranches.includes(branch)) { throw new DeploymentError('Branch is not allowed', 400); } } async #assertRepository() { const inside = await this.#gitText('rev-parse', '--is-inside-work-tree'); if (inside !== 'true') { throw new Error('IMAGE_REPOSITORY_PATH is not a Git worktree'); } } async #assertClean() { const status = await this.#gitRaw('status', '--porcelain=v1', '--untracked-files=no'); if (status.trim()) { throw new DeploymentError('Tracked worktree changes prevent deployment'); } } async #loadState() { try { const saved = JSON.parse(await readFile(this.config.statePath, 'utf8')); return { activeBranch: saved.activeBranch ?? this.config.defaultBranch, deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [], adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [], syncRequests: Array.isArray(saved.syncRequests) ? saved.syncRequests : [], lastSuccess: saved.lastSuccess ?? null, lastError: saved.lastError ?? null, }; } catch (error) { if (error.code !== 'ENOENT') { throw error; } return { activeBranch: this.config.defaultBranch, deliveries: [], adminRequests: [], syncRequests: [], lastSuccess: null, lastError: null, }; } } async #saveState() { const temporary = `${this.config.statePath}.tmp-${process.pid}`; await writeFile(temporary, JSON.stringify(this.state, null, 2), { encoding: 'utf8', mode: 0o600 }); await rename(temporary, this.config.statePath); } async #git(...args) { await this.run('git', ['-C', this.config.repositoryPath, ...args], { maxBuffer: 16 * 1024 * 1024 }); } async #gitRaw(...args) { const { stdout } = await this.run('git', ['-C', this.config.repositoryPath, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, }); return Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout; } async #gitText(...args) { return (await this.#gitRaw(...args)).trim(); } async #gitExit(...args) { try { await this.#git(...args); return 0; } catch (error) { return error.code ?? 1; } } }