/** * Getting a working tree for a run. The PDS serves these repositories over * read-only smart HTTP, so this is a plain clone with a stock git binary and * needs neither the remote helper nor credentials. */ import { spawn } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; /** * @param {string[]} args * @param {(chunk: string) => void} onLog * @param {Record} [env] - added to the environment * @returns {Promise} */ function git(args, onLog, env) { return new Promise((resolve, reject) => { const child = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env, }); let stderr = ''; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', onLog); child.stderr.on('data', (chunk) => { stderr += chunk; onLog(chunk); }); child.on('error', reject); child.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`)); }); }); } /** * The clone URL the PDS serves this repository at. * @param {string} service - PDS base URL * @param {string} did * @param {string} repoName * @returns {string} */ export function cloneUrl(service, did, repoName) { return `${service.replace(/\/+$/, '')}/git/${did}/${encodeURIComponent(repoName)}`; } /** * The remote helper's URL for the same repository. * @param {string} did * @param {string} repoName * @returns {string} */ export function helperUrl(did, repoName) { return `atproto://${did}/${encodeURIComponent(repoName)}`; } /** * Clone the repository and detach at one commit. The caller owns the * directory and passes it to `discardCheckout` when the run finishes. * * Two ways in. Smart HTTP needs no helper on the PATH, so it goes first. It * serves a repository by merging the bundle chain's packfiles, which git * rejects when a chain repeats an object, so a chain long enough to repeat * one falls back to the helper. The helper unbundles each bundle on its own * and does not care. * @param {Object} options * @param {string} options.service * @param {string} options.did * @param {string} options.repoName * @param {string} options.sha * @param {(chunk: string) => void} options.onLog * @returns {Promise} the working tree path */ export async function checkout(options) { const { service, did, repoName, sha, onLog } = options; const dir = await mkdtemp(join(tmpdir(), 'pdsjs-git-ci-')); try { try { await git( ['clone', '--quiet', cloneUrl(service, did, repoName), dir], onLog, ); } catch (httpErr) { onLog( `\n[smart HTTP clone failed: ${httpErr instanceof Error ? httpErr.message : httpErr}]\n[retrying with git-remote-atproto]\n`, ); await rm(dir, { recursive: true, force: true }); await git(['clone', '--quiet', helperUrl(did, repoName), dir], onLog, { // The helper resolves the authority through PLC without this. ATPROTO_GIT_SERVICE: service, }); } await git(['-C', dir, 'checkout', '--quiet', '--detach', sha], onLog); return dir; } catch (err) { await discardCheckout(dir); throw err; } } /** * @param {string} dir * @returns {Promise} */ export async function discardCheckout(dir) { await rm(dir, { recursive: true, force: true }); }