OverviewFilesCommitsIssuesDocsReleasesPeers

git-remote-punch / bin / git-remote-punch

289 lines · text ref:
#!/usr/bin/env bare
/**
 * git-remote-punch — Git remote helper for punch:// URLs.
 *
 * Primary P2P git transport. Provides clone/fetch/push over Hyperswarm
 * between a git client and a server that advertises repository topics
 * (e.g., pear-git-v3's hyper-sync, or pear-git v1's P2P handler).
 *
 * Usage:
 *   git clone punch://serverkey/repo
 *   git clone punch://petname/repo                                      # if petname configured
 *   git clone punch://serverkey/repo?https=https://fallback.url/repo.git # HTTPS fallback
 *
 * Git invokes this with two arguments:
 *   $1 = remote name (usually "origin" or the URL itself)
 *   $2 = remote URL (punch://...)
 *
 * Protocol:
 *   1. Parse punch:// URL to get server key and repo name
 *   2. Derive topic: hash(serverKey + repoName)
 *   3. Connect to topic via Hyperswarm (P2P)
 *   4. If P2P fails, fall back to HTTPS URL if provided
 *   5. Proxy stdin/stdout to connection
 */

// Bring Node globals into Bare.
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 { fileURLToPath } from 'bare-url'

// Resolve sibling lib/ from this script's own location, not cwd.
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const libDir = path.resolve(__dirname, '..', 'lib')

const { PetnameManager } = await import(path.join(libDir, 'petnames.js'))
const { TopicConnector } = await import(path.join(libDir, 'topic-connector.js'))
const { GitP2PClient } = await import(path.join(libDir, 'git-p2p-client.js'))

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 () {
  const config = loadConfig()
  const petnames = new PetnameManager()
  await petnames.init()

  let parsed
  let httpsUrl = null

  try {
    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 (!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('[punch] Resolved ' + petname + ' -> ' + key.slice(0, 8) + '...')
  }
  if (httpsUrl) {
    console.error('[punch] HTTPS fallback: ' + httpsUrl)
  }

  const stdin = process.stdin
  const stdout = process.stdout
  const stderr = process.stderr

  let buffer = Buffer.alloc(0)

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

    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':
      stdout.write('connect\n')
      stdout.write('\n')
      return false

    case 'connect': {
      const service = args[0]
      await handleConnect(service, serverKey, repoName, httpsUrl, stdout, stderr)
      return true
    }

    case 'list':
      stderr.write('[punch] list not implemented, use connect\n')
      stdout.write('\n')
      return false

    default:
      stderr.write('[punch] Unknown command: ' + cmd + '\n')
      process.exit(1)
      return false
  }
}

async function handleConnect (service, serverKey, repoName, httpsUrl, stdout, stderr) {
  stderr.write('[punch] Connecting to ' + serverKey.slice(0, 8) + '.../' + repoName + '\n')

  // Durable, event-driven connect (see lib/durable-connect.js): keep the swarm
  // joined, take connections as they arrive, NO aggressive deadline racing the
  // DHT+hole-punch. `fallbackAfter` is a GENEROUS no-progress escape only — if
  // NO peer is reached within it, we fall through to the HTTPS path below
  // rather than hang forever. It must never race a live connect (the old hard
  // 15s did, and starved concurrent org-repo pulls into ZERO-object failures —
  // pear-git org issue #3). The data transfer itself stays unbounded.
  const connector = new TopicConnector({ fallbackAfter: 120000 })
  await connector.init()

  try {
    stderr.write('[punch] Searching for peers...\n')
    const conn = await connector.connect(serverKey, repoName)
    stderr.write('[punch] 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')

    // No connection timeout — large repos (1+ GB) can transfer for minutes.
    // Backpressure keeps data flowing; stale connections are detected by
    // the stream's error/close events.

    // Tell git we're ready FIRST (before any data forwarding) so the blank
    // line precedes the server's ref advertisement on stdout.
    stdout.write('\n')

    let cleaningUp = false
    function cleanup (code) {
      if (cleaningUp) return
      cleaningUp = true
      // Graceful teardown — destroy swarm properly.
      // Requires udx-native >= 1.20.3 (libudx 0420f62) which fixes
      // use-after-free and double-free bugs in stream teardown.
      connector.destroy().catch(() => {}).then(() => process.exit(code))
    }

    // 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).
    //
    // Backpressure: when write() returns false, pause the source. Resume
    // on 'drain'. Without this, large repos overflow the send buffer and
    // cause "unexpected disconnect" / "bad pack header".
    let stdinEnded = false
    process.stdin.on('data', (chunk) => {
      if (!conn.write(chunk)) {
        process.stdin.pause()
      }
    })
    conn.on('drain', () => {
      if (!stdinEnded) process.stdin.resume()
    })

    process.stdin.on('end', () => {
      stdinEnded = true
      conn.end()
      // Safety: if conn.close doesn't fire within 10s after stdin ends, force
      // exit. Prevents io_uring spin when SecretStream teardown races.
      setTimeout(() => cleanup(0), 10000).unref()
    })

    conn.on('data', (chunk) => {
      if (!stdout.write(chunk)) {
        conn.pause()
      }
    })
    stdout.on('drain', () => conn.resume())

    conn.on('close', () => cleanup(0))

    conn.on('error', (err) => {
      stderr.write('[punch] Connection error: ' + err.message + '\n')
      cleanup(128)
    })
  } catch (err) {
    stderr.write('[punch] P2P connection failed: ' + err.message + '\n')

    // Destroy swarm before HTTPS fallback. Hard kill timeout as fallback —
    // connector.destroy() can trigger libudx double-free races.
    const hardKill = setTimeout(() => process.exit(128), 5000)
    hardKill.unref()
    try { await connector.destroy() } catch {}
    clearTimeout(hardKill)

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

async function handleHttpsFallback (service, httpsUrl, stdout, stderr) {
  const proc = spawn('git-remote-https', ['origin', httpsUrl], {
    stdio: ['pipe', 'pipe', 'pipe']
  })

  proc.stdin.write('connect ' + service + '\n')

  process.stdin.pipe(proc.stdin)
  proc.stdout.pipe(stdout)

  proc.stderr.on('data', (data) => {
    const msg = data.toString()
    if (msg.includes('error') || msg.includes('fatal')) {
      stderr.write(data)
    }
  })

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

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

main().catch(err => {
  console.error('[punch] Fatal error: ' + err.message)
  process.exit(1)
})