OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / bin / git-remote-pear

267 lines · text ref:
#!/usr/bin/env bare
/**
 * git-remote-pear - Git remote helper for pear:// URLs
 *
 * This script is called by git when using pear:// remotes:
 *   git clone pear://serverkey/repo
 *   git clone pear://petname/repo (if petname configured)
 *   git clone pear://serverkey/repo?https=https://fallback.url/repo.git
 *
 * Git passes two arguments:
 *   $1 = remote name (usually "origin" or the URL itself)
 *   $2 = remote URL (pear://...)
 *
 * Protocol:
 * 1. Parse pear:// URL to get server key and repo name
 * 2. Derive topic: hash(serverKey + repoName)
 * 3. Try to connect to topic via Hyperswarm (P2P)
 * 4. If P2P fails, fall back to HTTPS URL if provided
 * 5. Proxy stdin/stdout to connection
 *
 * HTTPS Fallback:
 * - Query param: pear://key/repo?https=https://example.com/repo.git
 * - Config file: ~/.config/pear-git/config.json { "defaultGateway": "https://..." }
 */

// Load Node.js compatibility for Bare runtime
await import('bare-node-runtime/global')

import { spawn } from 'bare-subprocess'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { PetnameManager } from '../lib/p2p/discovery/petnames.js'
import { TopicConnector } from '../lib/p2p/discovery/topic-manager.js'
import { GitP2PClient } from '../lib/p2p/protocol/git-p2p-handler.js'

// Load config for default gateway
function loadConfig() {
  try {
    const configPath = path.join(os.homedir(), '.config', 'pear-git', 'config.json')
    const data = fs.readFileSync(configPath, 'utf-8')
    return JSON.parse(data)
  } catch {
    return {}
  }
}

const args = process.argv.slice(2)
const remoteName = args[0]
const remoteUrl = args[1] || remoteName

async function main() {
  // Load config for default gateway
  const config = loadConfig()

  // Initialize petname manager
  const petnames = new PetnameManager()
  await petnames.init()

  // Parse pear:// URL (extract query params for fallback)
  let parsed
  let httpsUrl = null

  try {
    // Parse URL to extract query params
    const urlObj = new URL(remoteUrl)
    httpsUrl = urlObj.searchParams.get('https')

    // Remove query params for petname parsing
    const cleanUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
    parsed = petnames.parseUrl(cleanUrl)

    // If no explicit fallback, try to construct from default gateway
    if (!httpsUrl && config.defaultGateway) {
      httpsUrl = `${config.defaultGateway}/${parsed.repo}.git`
    }
  } catch (err) {
    console.error(`error: ${err.message}`)
    process.exit(128)
  }

  const { key, repo, isPetname, petname } = parsed

  if (isPetname) {
    console.error(`[pear] Resolved ${petname} -> ${key.slice(0, 8)}...`)
  }
  if (httpsUrl) {
    console.error(`[pear] HTTPS fallback: ${httpsUrl}`)
  }

  // Read commands from git (via stdin)
  const stdin = process.stdin
  const stdout = process.stdout
  const stderr = process.stderr

  // Buffer for reading lines (use raw buffers, convert to string for commands)
  let buffer = Buffer.alloc(0)

  const commandHandler = async (chunk) => {
    // Ensure chunk is a buffer
    const chunkBuf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
    buffer = Buffer.concat([buffer, chunkBuf])

    // Look for complete lines (newline = 0x0a)
    let newlineIdx
    while ((newlineIdx = buffer.indexOf(0x0a)) !== -1) {
      const line = buffer.slice(0, newlineIdx).toString('utf-8').trim()
      buffer = buffer.slice(newlineIdx + 1)

      if (line === '') continue

      const shouldPipe = await handleCommand(line, key, repo, httpsUrl, stdout, stderr)
      if (shouldPipe) {
        // CRITICAL: Remove this listener before piping starts.
        // In streamx, .on('data') and .pipe() compete for data.
        // If both are active, the data handler consumes chunks
        // that should flow through the pipe, breaking push.
        stdin.removeListener('data', commandHandler)
        return
      }
    }
  }

  stdin.on('data', commandHandler)

  stdin.on('end', () => {
    process.exit(0)
  })
}

async function handleCommand(command, serverKey, repoName, httpsUrl, stdout, stderr) {
  const [cmd, ...args] = command.split(' ')

  switch (cmd) {
    case 'capabilities':
      // Tell git what we support
      stdout.write('connect\n')
      stdout.write('\n')
      return false // Continue command mode

    case 'connect':
      const service = args[0] // git-upload-pack or git-receive-pack
      await handleConnect(service, serverKey, repoName, httpsUrl, stdout, stderr)
      return true // Switch to pipe mode

    case 'list':
      // For listing refs - could implement later
      stderr.write('[pear] list not implemented, use connect\n')
      stdout.write('\n')
      return false

    default:
      stderr.write(`[pear] Unknown command: ${cmd}\n`)
      process.exit(1)
      return false
  }
}

async function handleConnect(service, serverKey, repoName, httpsUrl, stdout, stderr) {
  stderr.write(`[pear] Connecting to ${serverKey.slice(0, 8)}.../${repoName}\n`)

  const connector = new TopicConnector({ timeout: 15000 })
  await connector.init()

  try {
    stderr.write('[pear] Searching for peers...\n')
    const conn = await connector.connect(serverKey, repoName)
    stderr.write('[pear] Connected via P2P!\n')

    const client = new GitP2PClient()
    await client.initiate(conn, service, repoName)

    // Remove ALL listeners from stdin before forwarding
    process.stdin.removeAllListeners('data')
    process.stdin.removeAllListeners('end')

    // Keep event loop alive during transfer
    const keepAlive = setInterval(() => {}, 30000)

    // Tell git we're ready FIRST (before data forwarding)
    stdout.write('\n')

    // Manual bidirectional forwarding — NOT pipe()!
    // streamx pipe() unconditionally calls dest.end() when source ends,
    // and SecretStream._final() closes both directions of the noise stream.
    // This kills push (server can't send status after client sends pack).
    process.stdin.on('data', (chunk) => {
      conn.write(chunk)
    })

    process.stdin.on('end', () => {
      conn.end()
    })

    conn.on('data', (chunk) => {
      stdout.write(chunk)
    })

    conn.on('close', () => {
      clearInterval(keepAlive)
      process.exit(0)
    })

    conn.on('error', (err) => {
      stderr.write(`[pear] Connection error: ${err.message}\n`)
      clearInterval(keepAlive)
      process.exit(128)
    })

  } catch (err) {
    stderr.write(`[pear] P2P connection failed: ${err.message}\n`)
    await connector.destroy()

    if (httpsUrl) {
      stderr.write(`[pear] Falling back to HTTPS: ${httpsUrl}\n`)
      await handleHttpsFallback(service, httpsUrl, stdout, stderr)
    } else {
      stderr.write('[pear] No HTTPS fallback available\n')
      process.exit(128)
    }
  }
}

/**
 * Fall back to HTTPS by spawning git-remote-https
 * This delegates the entire connection to git's native HTTPS handler
 */
async function handleHttpsFallback(service, httpsUrl, stdout, stderr) {
  // Use git-remote-https to handle the HTTPS connection
  // It expects the same protocol we speak (capabilities, connect, etc.)
  const proc = spawn('git-remote-https', ['origin', httpsUrl], {
    stdio: ['pipe', 'pipe', 'pipe']
  })

  // Forward the connect command we already received
  proc.stdin.write(`connect ${service}\n`)

  // Proxy: stdin -> proc.stdin, proc.stdout -> stdout
  process.stdin.pipe(proc.stdin)
  proc.stdout.pipe(stdout)

  proc.stderr.on('data', (data) => {
    // Filter out noise, only show errors
    const msg = data.toString()
    if (msg.includes('error') || msg.includes('fatal')) {
      stderr.write(data)
    }
  })

  proc.on('close', (code) => {
    if (code !== 0) {
      stderr.write(`[pear] HTTPS fallback exited with code ${code}\n`)
    }
    process.exit(code || 0)
  })

  proc.on('error', (err) => {
    stderr.write(`[pear] HTTPS fallback error: ${err.message}\n`)
    process.exit(128)
  })
}

main().catch(err => {
  console.error(`[pear] Fatal error: ${err.message}`)
  process.exit(1)
})