OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / archive.js

136 lines · javascript ref:
/**
 * 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)
    })
  })
}