import { createHash } from 'crypto' import { Octokit } from '@octokit/rest' import { LFS_EXTENSIONS } from './constants' import { isRecord } from './guards' import type { PushFile, RemoteFile } from './types' const LFS_BATCH_SIZE = 100 type LogDetails = Record interface GitRemoteConfig { apiBaseUrl: string lfsBatchUrl: string owner: string provider: 'github' | 'gitea' repo: string token: string webUrl: string } class GitApiError extends Error { constructor(message: string, public status: number) { super(message) } } function isHttpError(err: unknown): err is { status: number } { return isRecord(err) && typeof err.status === 'number' } function encodePath(path: string) { return path.split('/').map(encodeURIComponent).join('/') } function getRepoApiPath(remote: GitRemoteConfig) { return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}` } async function requestGitJson( remote: GitRemoteConfig, path: string, init: { method?: string; body?: unknown } = {}, ): Promise { const res = await fetch(`${remote.apiBaseUrl}${path}`, { method: init.method ?? 'GET', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': `token ${remote.token}`, }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }) if (!res.ok) { const text = await res.text() throw new GitApiError(text || `Git API request failed (${res.status})`, res.status) } return res.json() } function getGitToken() { const token = process.env.GIT_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() if (!token) throw new Error('GIT_TOKEN non configure') return token } function getGitUsername() { return process.env.GIT_USERNAME?.trim() } function getLfsAuthorizationHeader(remote: GitRemoteConfig) { if (remote.provider === 'github') return `token ${remote.token}` const username = getGitUsername() if (!username) { throw new Error('GIT_USERNAME non configure pour Git LFS sur Gitea') } return `Basic ${Buffer.from(`${username}:${remote.token}`, 'utf-8').toString('base64')}` } function getOctokit(remote: GitRemoteConfig): Octokit { return new Octokit({ auth: remote.token, baseUrl: remote.apiBaseUrl, }) } function cleanRepoName(repo: string) { return repo.replace(/\/+$/, '').replace(/\.git$/, '') } function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig { const normalizedHost = host.toLowerCase() const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}` const isGitHub = normalizedHost === 'github.com' return { apiBaseUrl: isGitHub ? 'https://api.github.com' : `${origin}/api/v1`, lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`, owner, provider: isGitHub ? 'github' : 'gitea', repo, token: getGitToken(), webUrl: `${origin}/${owner}/${repo}`, } } function parseRepoUrl(): GitRemoteConfig { const url = process.env.GIT_REPO_URL?.trim() if (!url) throw new Error('GIT_REPO_URL non configure') const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/) if (shortMatch) { return buildRemoteConfig('github.com', shortMatch[1], cleanRepoName(shortMatch[2])) } const scpLikeMatch = !url.includes('://') ? url.match(/^(?:[^@\s]+@)?([^:\s]+):([^/\s]+)\/([^/\s]+)$/) : null if (scpLikeMatch) { return buildRemoteConfig(scpLikeMatch[1], scpLikeMatch[2], cleanRepoName(scpLikeMatch[3])) } if (URL.canParse(url)) { const parsed = new URL(url) const pathParts = parsed.pathname .replace(/^\/+|\/+$/g, '') .split('/') .filter(Boolean) if ((parsed.protocol === 'https:' || parsed.protocol === 'http:' || parsed.protocol === 'ssh:') && pathParts.length >= 2) { return buildRemoteConfig(parsed.hostname, pathParts[0], cleanRepoName(pathParts[1]), parsed.protocol) } } throw new Error(`Format GIT_REPO_URL invalide: "${url}"`) } function isLfsFile(filePath: string): boolean { const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase() return LFS_EXTENSIONS.has(ext) } function buildLfsPointer(sha256: string, size: number): string { return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n` } 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) } } interface GitContentEntry { content?: string name: string path: string sha: string size: number type?: string } function isGitContentEntry(value: unknown): value is GitContentEntry { return isRecord(value) && typeof value.name === 'string' && typeof value.path === 'string' && typeof value.sha === 'string' && typeof value.size === 'number' && (value.content === undefined || typeof value.content === 'string') && (value.type === undefined || typeof value.type === 'string') } function decodeBase64Content(content: string) { return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8') } async function getRemoteContent(remote: GitRemoteConfig, path: string, branch: string) { const query = new URLSearchParams({ ref: branch }) return requestGitJson(remote, `${getRepoApiPath(remote)}/contents/${encodePath(path)}?${query.toString()}`) } async function getRemoteFileEntry(remote: GitRemoteConfig, path: string, branch: string): Promise { try { const data = await getRemoteContent(remote, path, branch) return isGitContentEntry(data) ? data : null } catch (err) { if (isHttpError(err) && err.status === 404) return null throw err } } 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 } interface LfsObject { oid: string size: number contentBase64: string } type LfsPushFile = PushFile & { oid: string size: number } interface LfsBatchAction { href: string header?: Record } interface LfsBatchObject { oid: string size: number actions?: { upload?: LfsBatchAction verify?: LfsBatchAction } error?: { code: number; message: string } } 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 } 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((o) => ({ oid: o.oid, size: o.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((o) => [o.oid, o])) for (const obj of batchObjects) { if (obj.error) { throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`) } if (!obj.actions?.upload) continue const local = objectMap.get(obj.oid) if (!local) continue const uploadAction = obj.actions.upload const headers: Record = { 'Content-Type': 'application/octet-stream', ...uploadAction.header, } const body = Buffer.from(local.contentBase64, 'base64') const uploadRes = await fetch(uploadAction.href, { method: 'PUT', headers, body, }) if (!uploadRes.ok) { const text = await uploadRes.text() throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`) } if (obj.actions.verify) { const verifyAction = obj.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: obj.oid, size: obj.size }), }) if (!verifyRes.ok) { const text = await verifyRes.text() throw new Error(`LFS verify failed for ${obj.oid} (${verifyRes.status}): ${text}`) } } } logInfo('Git LFS', `Batch ${batchNumber}/${totalBatches} done`, startedAt, { objects: objects.length, }) } interface GiteaFileOperation { content?: string operation: 'create' | 'update' | 'delete' path: string sha?: string } function getGiteaCommitUrl(value: unknown, remote: GitRemoteConfig, branch: string) { if (isRecord(value) && isRecord(value.commit)) { if (typeof value.commit.html_url === 'string') return value.commit.html_url if (typeof value.commit.sha === 'string') return `${remote.webUrl}/commit/${value.commit.sha}` } return `${remote.webUrl}/commits/branch/${branch}` } async function pushAllToGitea( remote: GitRemoteConfig, regularFiles: PushFile[], lfsFiles: LfsPushFile[], deletePaths: string[], commitMessage: string, branch: string, ): Promise<{ commitUrl: string }> { const committedFiles: PushFile[] = [ ...regularFiles, ...lfsFiles.map((file) => ({ path: file.path, contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'), })), ] const newFilePaths = new Set(committedFiles.map((file) => file.path)) const operations: GiteaFileOperation[] = [] for (const file of committedFiles) { const existing = await getRemoteFileEntry(remote, file.path, branch) operations.push({ content: file.contentBase64, operation: existing ? 'update' : 'create', path: file.path, sha: existing?.sha, }) } for (const path of deletePaths) { if (newFilePaths.has(path)) continue const existing = await getRemoteFileEntry(remote, path, branch) if (!existing) continue operations.push({ operation: 'delete', path, sha: existing.sha, }) } if (operations.length === 0) { return { commitUrl: `${remote.webUrl}/commits/branch/${branch}` } } const data = await requestGitJson(remote, `${getRepoApiPath(remote)}/contents`, { method: 'POST', body: { branch, files: operations, message: commitMessage, }, }) return { commitUrl: getGiteaCommitUrl(data, remote, branch) } } export async function getRemoteFolder( folderPath: string, ): Promise<{ exists: boolean; files: RemoteFile[] }> { const remote = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' try { const data = await getRemoteContent(remote, folderPath, branch) if (!Array.isArray(data)) { throw new Error(`Le chemin distant ${folderPath} existe mais ce n'est pas un dossier`) } const files: RemoteFile[] = await Promise.all( data.map(async (f: unknown): Promise => { if (!isGitContentEntry(f)) { throw new Error(`Reponse Git invalide pour ${folderPath}`) } if (!isLfsFile(f.name) || f.size > 1024) { return { name: f.name, size: f.size } } try { const fileData = await getRemoteFileEntry(remote, `${folderPath}/${f.name}`, branch) if (fileData?.content) { const content = decodeBase64Content(fileData.content) const pointer = parseLfsPointer(content) if (pointer) { return { name: f.name, size: pointer.size } } } } catch (err) { if (!isHttpError(err) || err.status !== 404) throw err } return { name: f.name, size: f.size } }), ) return { exists: true, files } } catch (err: unknown) { if (isHttpError(err) && err.status === 404) { return { exists: false, files: [] } } throw err } } export async function pushAllToGit( files: PushFile[], deletePaths: string[], commitMessage: string, ): Promise<{ commitUrl: string }> { const remote = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' const lfsFiles: LfsPushFile[] = [] const regularFiles: PushFile[] = [] for (const f of files) { if (isLfsFile(f.path)) { const buf = Buffer.from(f.contentBase64, 'base64') const oid = createHash('sha256').update(buf).digest('hex') lfsFiles.push({ ...f, oid, size: buf.length }) } else { regularFiles.push(f) } } if (lfsFiles.length > 0) { await uploadToLfs( remote, lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })), ) } if (remote.provider === 'gitea') { return pushAllToGitea(remote, regularFiles, lfsFiles, deletePaths, commitMessage, branch) } const octokit = getOctokit(remote) const { owner, repo } = remote const { data: ref } = await octokit.git.getRef({ owner, repo, ref: `heads/${branch}`, }) const latestCommitSha = ref.object.sha const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) const allFiles = [...regularFiles, ...lfsFiles] const blobResults = await Promise.all( allFiles.map((f) => { const lfs = lfsFiles.find((lf) => lf.path === f.path) if (lfs) { const pointer = buildLfsPointer(lfs.oid, lfs.size) return octokit.git.createBlob({ owner, repo, content: Buffer.from(pointer, 'utf-8').toString('base64'), encoding: 'base64', }) } return octokit.git.createBlob({ owner, repo, content: f.contentBase64, encoding: 'base64', }) }), ) const newFilePaths = new Set(files.map((f) => f.path)) const deleteEntries = deletePaths .filter((p) => !newFilePaths.has(p)) .map((p) => ({ path: p, mode: '100644' as const, type: 'blob' as const, sha: null, })) const { data: newTree } = await octokit.git.createTree({ owner, repo, base_tree: commit.tree.sha, tree: [ ...allFiles.map((f, i) => ({ path: f.path, mode: '100644' as const, type: 'blob' as const, sha: blobResults[i].data.sha, })), ...deleteEntries, ], }) const { data: newCommit } = await octokit.git.createCommit({ owner, repo, message: commitMessage, tree: newTree.sha, parents: [latestCommitSha], }) await octokit.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: newCommit.sha, }) return { commitUrl: newCommit.html_url || `${remote.webUrl}/commit/${newCommit.sha}` } }