/**
* Archive Generation Helper
*
* Generates tar.gz or zip archives from the release branch of bare git repos.
* Used by:
* - lib/routes/archive.js (HTTP endpoint, lazy generation)
* - lib/ci/deploy-hook.js (pre-build during deploy)
*/
import fs from 'bare-fs'
import path from 'bare-path'
import subprocess from 'bare-subprocess'
const VALID_FORMATS = ['tar.gz', 'zip']
const TIMEOUT_MS = 120000
/**
* Get the expected archive file path.
*
* @param {string} archiveDir - Base directory for archives
* @param {string} repoName - Repository name
* @param {string} format - 'tar.gz' or 'zip'
* @returns {string} Full path to the archive file
*/
export function getArchivePath (archiveDir, repoName, format) {
return path.join(archiveDir, repoName, `release.${format}`)
}
/**
* Generate an archive from the release branch of a bare git repo.
*
* 1. Validates format is 'tar.gz' or 'zip'
* 2. Verifies release branch exists
* 3. Creates output directory if needed
* 4. Runs `git archive` with --prefix={repoName}/
* 5. Returns metadata { path, size, format, commitHash }
*
* @param {string} bareRepoPath - Path to the bare git repo
* @param {string} repoName - Repository name (used as archive prefix)
* @param {string} archiveDir - Base directory for archive output
* @param {string} format - 'tar.gz' or 'zip'
* @returns {Promise<{ path: string, size: number, format: string, commitHash: string }>}
*/
export async function generateArchive (bareRepoPath, repoName, archiveDir, format, opts = {}) {
// 1. Validate format
if (!VALID_FORMATS.includes(format)) {
throw new Error(`Invalid archive format "${format}". Must be one of: ${VALID_FORMATS.join(', ')}`)
}
// 2. Verify release branch exists
try {
await execGit(bareRepoPath, ['rev-parse', '--verify', 'refs/heads/release'])
} catch (err) {
throw new Error(`Repository "${repoName}" has no release branch. Deploy first to create one.`)
}
// Get commit hash of release branch tip
const commitHash = (await execGit(bareRepoPath, ['rev-parse', 'refs/heads/release'])).trim()
// 3. Create output directory
const outDir = path.join(archiveDir, repoName)
await fs.promises.mkdir(outDir, { recursive: true })
// 4. Run git archive
const archSuffix = opts.arch ? `-${opts.arch}` : ''
const outFile = path.join(outDir, `release${archSuffix}.${format}`)
const archiveArgs = [
'archive',
`--format=${format}`,
`--prefix=${repoName}/`,
'-o', outFile,
'release'
]
// Arch-specific: only include by-arch/{host}/ + root launcher files
if (opts.arch) {
archiveArgs.push('--', `by-arch/${opts.arch}/`, 'index.js', 'package.json', 'version.txt', 'daemon/')
}
await execGit(bareRepoPath, archiveArgs)
// 5. Get file size and return metadata
const stat = await fs.promises.stat(outFile)
return {
path: outFile,
size: stat.size,
format,
commitHash
}
}
/**
* Run a git command against a bare repo.
* Uses --git-dir to point at the bare repo path.
*
* @param {string} bareRepoPath - Path to bare git repo
* @param {string[]} args - Git subcommand and arguments
* @returns {Promise<string>} stdout
*/
function execGit (bareRepoPath, args) {
const fullArgs = ['--git-dir', bareRepoPath, ...args]
return new Promise((resolve, reject) => {
const proc = subprocess.spawn('git', fullArgs, {
stdio: ['ignore', 'pipe', 'pipe']
})
const chunks = []
const errChunks = []
const timeout = setTimeout(() => {
proc.kill('SIGTERM')
reject(new Error(`git command timed out after ${TIMEOUT_MS / 1000}s: git ${fullArgs.join(' ')}`))
}, TIMEOUT_MS)
proc.stdout.on('data', (c) => chunks.push(c))
proc.stderr.on('data', (c) => errChunks.push(c))
proc.on('close', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(Buffer.concat(chunks).toString('utf-8'))
} else {
reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `git exited with code ${code}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}