import { createHash } from 'crypto' import { Octokit } from '@octokit/rest' import { LFS_EXTENSIONS } from './constants' import type { RemoteFile } from './types' // --------------------------------------------------------------------------- // Octokit helpers // --------------------------------------------------------------------------- function isHttpError(err: unknown): err is { status: number } { return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record).status === 'number' } function getOctokit(): Octokit { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN non configure') return new Octokit({ auth: token }) } function parseRepoUrl(): { owner: string; repo: string } { const url = process.env.GIT_REPO_URL if (!url) throw new Error('GIT_REPO_URL non configure') const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/) const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/) const shortMatch = url.match(/^([^/]+)\/([^/]+)$/) const match = httpsMatch || sshMatch || shortMatch if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`) return { owner: match[1], repo: match[2] } } // --------------------------------------------------------------------------- // Git LFS helpers // --------------------------------------------------------------------------- /** Check if a file path should be tracked by LFS based on its extension. */ function isLfsFile(filePath: string): boolean { const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase() return LFS_EXTENSIONS.has(ext) } /** Build an LFS pointer file (text content stored in the Git blob). */ function buildLfsPointer(sha256: string, size: number): string { return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n` } /** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */ 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 LfsObject { oid: string size: number contentBase64: string } /** * Upload binary objects to the Git LFS server via the Batch API. * * Flow: * 1. POST to the LFS batch endpoint with operation "upload" * 2. For each object that has an "upload" action, PUT the binary content * 3. If the server omits "actions", the object already exists — skip upload */ async function uploadToLfs( owner: string, repo: string, objects: LfsObject[], ): Promise { if (objects.length === 0) return const token = process.env.GITHUB_TOKEN! const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch` // 1. Batch request — ask for upload URLs const batchRes = await fetch(lfsUrl, { method: 'POST', headers: { 'Accept': 'application/vnd.git-lfs+json', 'Content-Type': 'application/vnd.git-lfs+json', 'Authorization': `token ${token}`, }, 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() throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`) } const batchData = (await batchRes.json()) as { objects: Array<{ oid: string size: number actions?: { upload?: { href: string; header?: Record } verify?: { href: string; header?: Record } } error?: { code: number; message: string } }> } // 2. Upload each object that has an upload action const objectMap = new Map(objects.map((o) => [o.oid, o])) for (const obj of batchData.objects) { if (obj.error) { throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`) } // No actions = server already has this object, skip 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}`) } // 3. Verify if required 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}`) } } } } // --------------------------------------------------------------------------- // Read remote folder contents (with real file sizes for LFS files) // --------------------------------------------------------------------------- export async function getRemoteFolder( folderPath: string, ): Promise<{ exists: boolean; files: RemoteFile[] }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' try { const { data } = await octokit.repos.getContent({ owner, repo, path: folderPath, ref: branch, }) if (!Array.isArray(data)) { return { exists: false, files: [] } } // For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes), // not the real file size. We need to fetch each LFS pointer to get the real size. const files: RemoteFile[] = await Promise.all( data.map(async (f): Promise => { if (!isLfsFile(f.name) || f.size > 1024) { // Not LFS or too large to be a pointer — use size as-is return { name: f.name, size: f.size } } // Fetch the blob content to check if it's an LFS pointer try { const { data: fileData } = await octokit.repos.getContent({ owner, repo, path: `${folderPath}/${f.name}`, ref: branch, }) if (!Array.isArray(fileData) && 'content' in fileData && fileData.content) { const content = Buffer.from(fileData.content, 'base64').toString('utf-8') const pointer = parseLfsPointer(content) if (pointer) { return { name: f.name, size: pointer.size } } } } catch { // Fall through to use the original size } 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 } } // --------------------------------------------------------------------------- // Push all files in a single commit (with optional deletions + LFS support) // --------------------------------------------------------------------------- export async function pushAllToGitHub( files: { path: string; contentBase64: string }[], deletePaths: string[], commitMessage: string, ): Promise<{ commitUrl: string }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' // --- Separate LFS files from regular files --- const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = [] const regularFiles: { path: string; contentBase64: string }[] = [] 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) } } // --- Upload LFS objects to the LFS server --- if (lfsFiles.length > 0) { await uploadToLfs( owner, repo, lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })), ) } // 1. Get latest commit on branch const { data: ref } = await octokit.git.getRef({ owner, repo, ref: `heads/${branch}`, }) const latestCommitSha = ref.object.sha // 2. Get that commit's tree const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) // 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs const allFiles = [...regularFiles, ...lfsFiles] const blobResults = await Promise.all( allFiles.map((f) => { const lfs = lfsFiles.find((lf) => lf.path === f.path) if (lfs) { // Create a blob with the LFS pointer text (NOT the binary content) const pointer = buildLfsPointer(lfs.oid, lfs.size) return octokit.git.createBlob({ owner, repo, content: Buffer.from(pointer, 'utf-8').toString('base64'), encoding: 'base64', }) } // Regular file — push content as-is return octokit.git.createBlob({ owner, repo, content: f.contentBase64, encoding: 'base64', }) }), ) // 4. Build tree entries: new/changed files + deletions 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, ], }) // 5. Create a single commit const { data: newCommit } = await octokit.git.createCommit({ owner, repo, message: commitMessage, tree: newTree.sha, parents: [latestCommitSha], }) // 6. Update branch ref await octokit.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: newCommit.sha, }) return { commitUrl: newCommit.html_url } }