import { createHash } from 'crypto' import { LFS_EXTENSIONS } from '@/lib/constants' import { isRecord } from '@/lib/guards' import type { GitRemoteConfig, LfsObject, LfsPushFile } from './types' import type { PushFile } from '@/lib/types' const LFS_BATCH_SIZE = 100 type LogDetails = Record interface LfsBatchAction { href: string header?: Record } interface LfsBatchObject { oid: string size: number actions?: { upload?: LfsBatchAction verify?: LfsBatchAction } error?: { code: number; message: string } } export function isLfsFile(filePath: string): boolean { const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase() return LFS_EXTENSIONS.has(ext) } function formatElapsed(startedAt: number) { return `${((performance.now() - startedAt) / 1000).toFixed(1)}s` } function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) { console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '') } function chunkArray(items: T[], size: number) { const chunks: T[][] = [] for (let i = 0; i < items.length; i += size) { chunks.push(items.slice(i, i + size)) } return chunks } function isStringRecord(value: unknown): value is Record { return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string') } function parseLfsAction(value: unknown): LfsBatchAction | undefined { if (!isRecord(value) || typeof value.href !== 'string') return undefined return { href: value.href, header: isStringRecord(value.header) ? value.header : undefined, } } function parseLfsBatchObject(value: unknown): LfsBatchObject | null { if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null const actions = isRecord(value.actions) ? { upload: parseLfsAction(value.actions.upload), verify: parseLfsAction(value.actions.verify), } : undefined const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string' ? { code: value.error.code, message: value.error.message } : undefined return { oid: value.oid, size: value.size, actions, error } } function parseLfsBatchResponse(value: unknown): LfsBatchObject[] { if (!isRecord(value) || !Array.isArray(value.objects)) { throw new Error('LFS batch response invalide') } const objects: LfsBatchObject[] = [] for (const object of value.objects) { const parsed = parseLfsBatchObject(object) if (!parsed) { throw new Error('LFS batch object invalide') } objects.push(parsed) } return objects } function getLfsAuthorizationHeader(remote: GitRemoteConfig) { if (remote.provider === 'github') return `token ${remote.token}` if (!remote.username) { throw new Error('GIT_USERNAME non configure pour Git LFS sur Gitea') } return `Basic ${Buffer.from(`${remote.username}:${remote.token}`, 'utf-8').toString('base64')}` } export function buildLfsPointer(sha256: string, size: number): string { return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n` } export function parseLfsPointer(content: string): { oid: string; size: number } | null { if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null const sizeMatch = content.match(/^size (\d+)$/m) const oidMatch = content.match(/^oid sha256:([a-f0-9]{64})$/m) if (!sizeMatch || !oidMatch) return null return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) } } export function splitLfsFiles(files: PushFile[]) { const lfsFiles: LfsPushFile[] = [] const regularFiles: PushFile[] = [] for (const file of files) { if (isLfsFile(file.path)) { const buffer = Buffer.from(file.contentBase64, 'base64') const oid = createHash('sha256').update(buffer).digest('hex') lfsFiles.push({ ...file, oid, size: buffer.length }) } else { regularFiles.push(file) } } return { lfsFiles, regularFiles } } export async function uploadToLfs(remote: GitRemoteConfig, objects: LfsObject[]): Promise { if (objects.length === 0) return const batches = chunkArray(objects, LFS_BATCH_SIZE) for (let i = 0; i < batches.length; i++) { await uploadToLfsBatch(remote, batches[i], i + 1, batches.length) } } async function uploadToLfsBatch( remote: GitRemoteConfig, objects: LfsObject[], batchNumber: number, totalBatches: number, ): Promise { const startedAt = performance.now() logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} started`, startedAt, { objects: objects.length, }) const batchRes = await fetch(remote.lfsBatchUrl, { method: 'POST', headers: { 'Accept': 'application/vnd.git-lfs+json', 'Content-Type': 'application/vnd.git-lfs+json', 'Authorization': getLfsAuthorizationHeader(remote), }, body: JSON.stringify({ operation: 'upload', transfers: ['basic'], objects: objects.map((object) => ({ oid: object.oid, size: object.size })), }), }) if (!batchRes.ok) { const text = await batchRes.text() console.error(`[ERROR] Git LFS -> Batch ${batchNumber}/${totalBatches} failed | Timer: ${formatElapsed(startedAt)}`, { objects: objects.length, status: batchRes.status, }) throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`) } const batchData: unknown = await batchRes.json() const batchObjects = parseLfsBatchResponse(batchData) const objectMap = new Map(objects.map((object) => [object.oid, object])) for (const object of batchObjects) { if (object.error) { throw new Error(`LFS error for ${object.oid}: ${object.error.message} (${object.error.code})`) } if (!object.actions?.upload) continue const local = objectMap.get(object.oid) if (!local) continue const uploadAction = object.actions.upload const headers: Record = { 'Content-Type': 'application/octet-stream', ...uploadAction.header, } const uploadRes = await fetch(uploadAction.href, { method: 'PUT', headers, body: Buffer.from(local.contentBase64, 'base64'), }) if (!uploadRes.ok) { const text = await uploadRes.text() throw new Error(`LFS upload failed for ${object.oid} (${uploadRes.status}): ${text}`) } if (object.actions.verify) { const verifyAction = object.actions.verify const verifyHeaders: Record = { 'Accept': 'application/vnd.git-lfs+json', 'Content-Type': 'application/vnd.git-lfs+json', ...verifyAction.header, } const verifyRes = await fetch(verifyAction.href, { method: 'POST', headers: verifyHeaders, body: JSON.stringify({ oid: object.oid, size: object.size }), }) if (!verifyRes.ok) { const text = await verifyRes.text() throw new Error(`LFS verify failed for ${object.oid} (${verifyRes.status}): ${text}`) } } } logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} done`, startedAt, { objects: objects.length, }) }