OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / git-protocol.js

294 lines · javascript ref:
import subprocess from 'bare-subprocess'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

/**
 * Git Smart HTTP Protocol Handler
 *
 * Implements the git smart HTTP protocol for clone/push operations.
 * Uses native git commands to serve repositories.
 */
export class GitProtocolHandler {
  constructor(options = {}) {
    this.gitReposPath = options.gitReposPath || path.join(os.homedir(), 'git-repos')
  }

  /**
   * Get the path to a bare repository
   */
  getRepoPath(username, repoName) {
    // For now, ignore username and just look in git-repos
    const repoPath = path.join(this.gitReposPath, `${repoName}.git`)
    return repoPath
  }

  /**
   * Check if a repository exists
   */
  async repoExists(username, repoName) {
    const repoPath = this.getRepoPath(username, repoName)
    try {
      const stat = await fs.promises.stat(repoPath)
      return stat.isDirectory()
    } catch {
      return false
    }
  }

  /**
   * Handle /info/refs endpoint
   *
   * GET /repo.git/info/refs?service=git-upload-pack
   * GET /repo.git/info/refs?service=git-receive-pack
   */
  async handleInfoRefs(req, res, username, repoName) {
    const url = new URL(req.url, `http://${req.headers.host}`)
    const service = url.searchParams.get('service')

    if (!service || !['git-upload-pack', 'git-receive-pack'].includes(service)) {
      res.statusCode = 400
      res.end('Bad Request: Missing or invalid service parameter')
      return
    }

    const repoPath = this.getRepoPath(username, repoName)

    // Check if repo exists
    if (!await this.repoExists(username, repoName)) {
      res.statusCode = 404
      res.end('Repository not found')
      return
    }

    try {
      // Run git command to get refs (git-upload-pack or git-receive-pack)
      const proc = subprocess.spawn(service, ['--stateless-rpc', '--advertise-refs', repoPath], {
        stdio: ['ignore', 'pipe', 'pipe']
      })

      // Collect stdout and stderr
      const chunks = []
      const errorChunks = []

      proc.stdout.on('data', (chunk) => {
        chunks.push(chunk)
      })

      proc.stderr.on('data', (chunk) => {
        errorChunks.push(chunk)
      })

      // Wait for process to complete
      await new Promise((resolve, reject) => {
        const timeout = setTimeout(() => {
          proc.kill('SIGTERM')
          reject(new Error(`Git info-refs timed out after 60s`))
        }, 60000)

        proc.on('exit', (code) => {
          clearTimeout(timeout)
          if (code === 0) {
            resolve()
          } else {
            const errorMsg = Buffer.concat(errorChunks).toString()
            reject(new Error(`Git command failed with code ${code}: ${errorMsg}`))
          }
        })
        proc.on('error', (err) => {
          clearTimeout(timeout)
          reject(err)
        })
      })

      const output = Buffer.concat(chunks)

      // Format response with git smart HTTP framing
      const serviceHeader = this.pktLine(`# service=${service}\n`)
      const flushPkt = '0000'

      res.statusCode = 200
      res.setHeader('Content-Type', `application/x-${service}-advertisement`)
      res.setHeader('Cache-Control', 'no-cache')

      // Write service header
      res.write(serviceHeader)
      res.write(flushPkt)

      // Write refs
      res.write(output)
      res.end()
    } catch (err) {
      console.error('Git info-refs error:', err)
      res.statusCode = 500
      res.end('Internal Server Error')
    }
  }

  /**
   * Handle /git-upload-pack endpoint (clone/fetch)
   *
   * POST /repo.git/git-upload-pack
   */
  async handleUploadPack(req, res, username, repoName) {
    const repoPath = this.getRepoPath(username, repoName)

    // Check if repo exists
    if (!await this.repoExists(username, repoName)) {
      res.statusCode = 404
      res.end('Repository not found')
      return
    }

    try {
      // Spawn git-upload-pack
      const proc = subprocess.spawn('git-upload-pack', ['--stateless-rpc', repoPath], {
        stdio: ['pipe', 'pipe', 'pipe']
      })

      // 5 min timeout for large clone/fetch operations
      const timeout = setTimeout(() => {
        console.error('[Git] upload-pack timed out after 5 min')
        proc.kill('SIGTERM')
      }, 300000)

      // Set response headers immediately
      res.statusCode = 200
      res.setHeader('Content-Type', 'application/x-git-upload-pack-result')
      res.setHeader('Cache-Control', 'no-cache')

      // Pipe request body to git stdin
      req.on('data', (chunk) => {
        proc.stdin.write(chunk)
      })

      req.on('end', () => {
        proc.stdin.end()
      })

      // Pipe git stdout to response
      proc.stdout.on('data', (chunk) => {
        res.write(chunk)
      })

      // Handle process completion
      proc.on('exit', (code) => {
        clearTimeout(timeout)
        if (code !== 0) {
          console.error(`Git upload-pack exited with code ${code}`)
        }
        res.end()
      })

      proc.on('error', (err) => {
        clearTimeout(timeout)
        console.error('Git upload-pack error:', err)
        if (!res.headersSent) {
          res.statusCode = 500
          res.end('Internal Server Error')
        } else {
          res.end()
        }
      })

      // Handle stderr
      proc.stderr.on('data', (chunk) => {
        console.error('Git upload-pack stderr:', chunk.toString())
      })
    } catch (err) {
      console.error('Git upload-pack error:', err)
      res.statusCode = 500
      res.end('Internal Server Error')
    }
  }

  /**
   * Handle /git-receive-pack endpoint (push)
   *
   * POST /repo.git/git-receive-pack
   */
  async handleReceivePack(req, res, username, repoName) {
    const repoPath = this.getRepoPath(username, repoName)

    // Check if repo exists
    if (!await this.repoExists(username, repoName)) {
      res.statusCode = 404
      res.end('Repository not found')
      return
    }

    try {
      // Spawn git-receive-pack
      const proc = subprocess.spawn('git-receive-pack', ['--stateless-rpc', repoPath], {
        stdio: ['pipe', 'pipe', 'pipe']
      })

      // 5 min timeout for large push operations
      const timeout = setTimeout(() => {
        console.error('[Git] receive-pack timed out after 5 min')
        proc.kill('SIGTERM')
      }, 300000)

      // Set response headers immediately
      res.statusCode = 200
      res.setHeader('Content-Type', 'application/x-git-receive-pack-result')
      res.setHeader('Cache-Control', 'no-cache')

      // Pipe request body to git stdin
      req.on('data', (chunk) => {
        proc.stdin.write(chunk)
      })

      req.on('end', () => {
        proc.stdin.end()
      })

      // Pipe git stdout to response
      proc.stdout.on('data', (chunk) => {
        res.write(chunk)
      })

      // Handle process completion
      proc.on('exit', (code) => {
        clearTimeout(timeout)
        if (code !== 0) {
          console.error(`Git receive-pack exited with code ${code}`)
        }
        res.end()
      })

      proc.on('error', (err) => {
        clearTimeout(timeout)
        console.error('Git receive-pack error:', err)
        if (!res.headersSent) {
          res.statusCode = 500
          res.end('Internal Server Error')
        } else {
          res.end()
        }
      })

      // Handle stderr (progress messages)
      proc.stderr.on('data', (chunk) => {
        // Git sends progress to stderr, could forward to client if needed
        console.log('Git receive-pack:', chunk.toString())
      })
    } catch (err) {
      console.error('Git receive-pack error:', err)
      res.statusCode = 500
      res.end('Internal Server Error')
    }
  }

  /**
   * Create a pkt-line formatted string
   *
   * Git uses 4-byte hex length prefix + data format
   */
  pktLine(data) {
    const len = data.length + 4
    const hexLen = len.toString(16).padStart(4, '0')
    return hexLen + data
  }
}