import fs from 'node:fs'; import { sync as commandExistsSync } from 'command-exists'; import git from 'isomorphic-git'; import http from 'isomorphic-git/http/node'; import simpleGit from 'simple-git'; /** @type {{ AUTO: 'auto', SYSTEM: 'system', BUILTIN: 'builtin' }} */ export const GIT_BACKENDS = { AUTO: 'auto', SYSTEM: 'system', BUILTIN: 'builtin', }; /** * @param {string | undefined | null} preferredBackend * @returns {'system' | 'builtin'} */ function resolveBackend(preferredBackend) { const normalized = typeof preferredBackend === 'string' ? preferredBackend.trim().toLowerCase() : GIT_BACKENDS.AUTO; const backend = normalized === GIT_BACKENDS.SYSTEM ? GIT_BACKENDS.SYSTEM : normalized === GIT_BACKENDS.BUILTIN ? GIT_BACKENDS.BUILTIN : GIT_BACKENDS.AUTO; const systemGitAvailable = commandExistsSync('git'); if (backend === GIT_BACKENDS.SYSTEM && !systemGitAvailable) { throw new Error('System git backend is configured, but no git binary was found in PATH.'); } if (backend === GIT_BACKENDS.SYSTEM || (backend === GIT_BACKENDS.AUTO && systemGitAvailable)) { return GIT_BACKENDS.SYSTEM; } return GIT_BACKENDS.BUILTIN; } /** * @typedef {object} GitCloneOptions * @property {number} [depth] * @property {string} [branch] */ const SUPPORTED_CLONE_OPTIONS = new Set(['depth', 'branch']); /** * @param {GitCloneOptions} [options] * @returns {{ depth?: number, branch?: string }} */ function normalizeCloneOptions(options = {}) { for (const key of Object.keys(options)) { if (!SUPPORTED_CLONE_OPTIONS.has(key)) { throw new Error(`Unsupported clone option: ${key}`); } } return { depth: options.depth, branch: options.branch }; } /** * @typedef {object} GitClient * @property {'system' | 'builtin'} backend * @property {(url: string, localPath: string, options?: GitCloneOptions) => Promise} clone */ /** * @param {{ backend?: string }} [options] * @returns {GitClient} */ export function createGitClient(options = {}) { const backend = resolveBackend(options.backend); if (backend === GIT_BACKENDS.SYSTEM) { return new SimpleGitClient(); } return new IsomorphicGitClient(); } /** * @implements {GitClient} */ class SimpleGitClient { constructor() { this.backend = GIT_BACKENDS.SYSTEM; this.git = simpleGit(); } /** * @param {string} url * @param {string} localPath * @param {GitCloneOptions} [options] * @returns {Promise} */ async clone(url, localPath, options = {}) { const { depth, branch } = normalizeCloneOptions(options); /** @type {Record} */ const cloneOptions = {}; if (depth !== undefined) { cloneOptions['--depth'] = depth; } if (branch) { cloneOptions['--branch'] = branch; } await this.git.clone(url, localPath, cloneOptions); } } /** * @implements {GitClient} */ class IsomorphicGitClient { constructor() { this.backend = GIT_BACKENDS.BUILTIN; } /** * @param {string} url * @param {string} localPath * @param {GitCloneOptions} [options] * @returns {Promise} */ async clone(url, localPath, options = {}) { const { depth, branch } = normalizeCloneOptions(options); await git.clone({ fs, http, dir: localPath, url, depth, ref: branch, singleBranch: depth !== undefined || Boolean(branch), }); } }