refactor: split git provider adapters

This commit is contained in:
Tom Boullay
2026-05-17 14:12:09 +02:00
parent 377ed7cfb3
commit 81c513ee1f
13 changed files with 779 additions and 644 deletions
+85
View File
@@ -0,0 +1,85 @@
import type { GitProviderName, GitRemoteConfig } from './types'
const DEFAULT_GIT_BRANCH = 'main'
function getGitProviderOverride(): GitProviderName | undefined {
const provider = process.env.GIT_PROVIDER?.trim().toLowerCase()
if (!provider) return undefined
if (provider !== 'github' && provider !== 'gitea') {
throw new Error(`GIT_PROVIDER invalide: "${provider}"`)
}
return provider
}
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 cleanRepoName(repo: string) {
return repo.replace(/\/+$/, '').replace(/\.git$/, '')
}
function resolveProvider(host: string): GitProviderName {
const override = getGitProviderOverride()
if (override) return override
return host.toLowerCase() === 'github.com' ? 'github' : 'gitea'
}
function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig {
const provider = resolveProvider(host)
const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}`
return {
apiBaseUrl: provider === 'github' ? 'https://api.github.com' : `${origin}/api/v1`,
lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`,
owner,
provider,
repo,
token: getGitToken(),
username: process.env.GIT_USERNAME?.trim() || undefined,
webUrl: `${origin}/${owner}/${repo}`,
}
}
export function getGitBranch() {
return process.env.GIT_BRANCH?.trim() || DEFAULT_GIT_BRANCH
}
export function readGitRemoteConfig(): 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) {
const providerOverride = getGitProviderOverride()
if (providerOverride === 'gitea') {
throw new Error('GIT_REPO_URL doit inclure le host pour Gitea')
}
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}"`)
}
+102
View File
@@ -0,0 +1,102 @@
import { isRecord } from '@/lib/guards'
import type { RemoteFile } from '@/lib/types'
import { decodeBase64Content, encodePath, getRepoApiPath, isHttpError, requestGitJson } from './http'
import { isLfsFile, parseLfsPointer } from './lfs'
import type { GitRemoteConfig } from './types'
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'
&& (value.path === undefined || typeof value.path === 'string')
&& (value.sha === undefined || typeof value.sha === 'string')
&& (value.size === undefined || typeof value.size === 'number')
&& (value.content === undefined || typeof value.content === 'string')
&& (value.type === undefined || typeof value.type === 'string')
}
function getContentEntrySize(entry: GitContentEntry) {
return entry.size ?? 0
}
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()}`)
}
export function getRequiredContentEntrySha(entry: GitContentEntry, path: string) {
if (!entry.sha) {
throw new Error(`SHA Git manquant pour ${path}`)
}
return entry.sha
}
export async function getRemoteFileEntry(remote: GitRemoteConfig, path: string, branch: string): Promise<GitContentEntry | null> {
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
}
}
export async function readRemoteFolder(
remote: GitRemoteConfig,
branch: string,
folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> {
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 (entry: unknown): Promise<RemoteFile> => {
if (!isGitContentEntry(entry)) {
throw new Error(`Reponse Git invalide pour ${folderPath}`)
}
const size = getContentEntrySize(entry)
if (!isLfsFile(entry.name) || size > 1024) {
return { name: entry.name, size }
}
try {
const fileData = await getRemoteFileEntry(remote, `${folderPath}/${entry.name}`, branch)
if (fileData?.content) {
const content = decodeBase64Content(fileData.content)
const pointer = parseLfsPointer(content)
if (pointer) {
return { name: entry.name, size: pointer.size }
}
}
} catch (err) {
if (!isHttpError(err) || err.status !== 404) throw err
}
return { name: entry.name, size }
}),
)
return { exists: true, files }
} catch (err: unknown) {
if (isHttpError(err) && err.status === 404) {
return { exists: false, files: [] }
}
throw err
}
}
+47
View File
@@ -0,0 +1,47 @@
import { isRecord } from '@/lib/guards'
import type { GitRemoteConfig } from './types'
export class GitApiError extends Error {
constructor(message: string, public status: number) {
super(message)
}
}
export function isHttpError(err: unknown): err is { status: number } {
return isRecord(err) && typeof err.status === 'number'
}
export function encodePath(path: string) {
return path.split('/').map(encodeURIComponent).join('/')
}
export function getRepoApiPath(remote: GitRemoteConfig) {
return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}`
}
export function decodeBase64Content(content: string) {
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
}
export async function requestGitJson(
remote: GitRemoteConfig,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<unknown> {
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()
}
+24
View File
@@ -0,0 +1,24 @@
import { getGitBranch, readGitRemoteConfig } from './config'
import { createGiteaProvider } from './providers/gitea'
import { createGitHubProvider } from './providers/github'
import type { GitProvider } from './types'
import type { PushFile } from '@/lib/types'
function createGitProvider(): GitProvider {
const remote = readGitRemoteConfig()
const branch = getGitBranch()
if (remote.provider === 'github') {
return createGitHubProvider(remote, branch)
}
return createGiteaProvider(remote, branch)
}
export async function getRemoteFolder(folderPath: string) {
return createGitProvider().getRemoteFolder(folderPath)
}
export async function pushAllToGit(files: PushFile[], deletePaths: string[], commitMessage: string) {
return createGitProvider().pushFiles({ files, deletePaths, commitMessage })
}
+233
View File
@@ -0,0 +1,233 @@
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<string, string | number | boolean | undefined>
interface LfsBatchAction {
href: string
header?: Record<string, string>
}
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<T>(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<string, string> {
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<void> {
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<void> {
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<string, string> = {
'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<string, string> = {
'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,
})
}
+102
View File
@@ -0,0 +1,102 @@
import { getRemoteFileEntry, getRequiredContentEntrySha, readRemoteFolder } from '../content'
import { getRepoApiPath, requestGitJson } from '../http'
import { buildLfsPointer, splitLfsFiles, uploadToLfs } from '../lfs'
import type { GitProvider, GitRemoteConfig, LfsPushFile, PushFilesParams } from '../types'
import type { PushFile } from '@/lib/types'
import { isRecord } from '@/lib/guards'
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}`
}
function buildCommittedFiles(regularFiles: PushFile[], lfsFiles: LfsPushFile[]): PushFile[] {
return [
...regularFiles,
...lfsFiles.map((file) => ({
path: file.path,
contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'),
})),
]
}
async function buildGiteaOperations(
remote: GitRemoteConfig,
branch: string,
committedFiles: PushFile[],
deletePaths: string[],
) {
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 ? getRequiredContentEntrySha(existing, file.path) : undefined,
})
}
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: getRequiredContentEntrySha(existing, path),
})
}
return operations
}
export function createGiteaProvider(remote: GitRemoteConfig, branch: string): GitProvider {
return {
getRemoteFolder(folderPath) {
return readRemoteFolder(remote, branch, folderPath)
},
async pushFiles({ files, deletePaths, commitMessage }: PushFilesParams) {
const { lfsFiles, regularFiles } = splitLfsFiles(files)
await uploadToLfs(
remote,
lfsFiles.map((file) => ({ oid: file.oid, size: file.size, contentBase64: file.contentBase64 })),
)
const committedFiles = buildCommittedFiles(regularFiles, lfsFiles)
const operations = await buildGiteaOperations(remote, branch, committedFiles, deletePaths)
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) }
},
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Octokit } from '@octokit/rest'
import { readRemoteFolder } from '../content'
import { buildLfsPointer, splitLfsFiles, uploadToLfs } from '../lfs'
import type { GitProvider, GitRemoteConfig, PushFilesParams } from '../types'
export function createGitHubProvider(remote: GitRemoteConfig, branch: string): GitProvider {
const octokit = new Octokit({
auth: remote.token,
baseUrl: remote.apiBaseUrl,
})
return {
getRemoteFolder(folderPath) {
return readRemoteFolder(remote, branch, folderPath)
},
async pushFiles({ files, deletePaths, commitMessage }: PushFilesParams) {
const { lfsFiles, regularFiles } = splitLfsFiles(files)
await uploadToLfs(
remote,
lfsFiles.map((file) => ({ oid: file.oid, size: file.size, contentBase64: file.contentBase64 })),
)
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((file) => {
const lfs = lfsFiles.find((lfsFile) => lfsFile.path === file.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: file.contentBase64,
encoding: 'base64',
})
}),
)
const newFilePaths = new Set(files.map((file) => file.path))
const deleteEntries = deletePaths
.filter((path) => !newFilePaths.has(path))
.map((path) => ({
path,
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((file, index) => ({
path: file.path,
mode: '100644' as const,
type: 'blob' as const,
sha: blobResults[index].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}` }
},
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { PushFile, RemoteFile } from '@/lib/types'
export type GitProviderName = 'github' | 'gitea'
export interface GitRemoteConfig {
apiBaseUrl: string
lfsBatchUrl: string
owner: string
provider: GitProviderName
repo: string
token: string
username?: string
webUrl: string
}
export interface LfsObject {
oid: string
size: number
contentBase64: string
}
export type LfsPushFile = PushFile & {
oid: string
size: number
}
export interface PushFilesParams {
commitMessage: string
deletePaths: string[]
files: PushFile[]
}
export interface GitProvider {
getRemoteFolder(folderPath: string): Promise<{ exists: boolean; files: RemoteFile[] }>
pushFiles(params: PushFilesParams): Promise<{ commitUrl: string }>
}
-640
View File
@@ -1,640 +0,0 @@
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<string, string | number | boolean | undefined>
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<unknown> {
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'
&& (value.path === undefined || typeof value.path === 'string')
&& (value.sha === undefined || typeof value.sha === 'string')
&& (value.size === undefined || typeof value.size === 'number')
&& (value.content === undefined || typeof value.content === 'string')
&& (value.type === undefined || typeof value.type === 'string')
}
function getContentEntrySize(entry: GitContentEntry) {
return entry.size ?? 0
}
function getRequiredContentEntrySha(entry: GitContentEntry, path: string) {
if (!entry.sha) {
throw new Error(`SHA Git manquant pour ${path}`)
}
return entry.sha
}
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<GitContentEntry | null> {
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<T>(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<string, string>
}
interface LfsBatchObject {
oid: string
size: number
actions?: {
upload?: LfsBatchAction
verify?: LfsBatchAction
}
error?: { code: number; message: string }
}
function isStringRecord(value: unknown): value is Record<string, string> {
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<void> {
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<void> {
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<string, string> = {
'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<string, string> = {
'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 ? getRequiredContentEntrySha(existing, file.path) : undefined,
})
}
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: getRequiredContentEntrySha(existing, path),
})
}
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<RemoteFile> => {
if (!isGitContentEntry(f)) {
throw new Error(`Reponse Git invalide pour ${folderPath}`)
}
const size = getContentEntrySize(f)
if (!isLfsFile(f.name) || size > 1024) {
return { name: f.name, 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 }
}),
)
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}` }
}