#!/usr/bin/env bare
/**
* git-remote-punch - Git remote helper for punch:// URLs
*
* Primary P2P git transport for pear-git.
*
* 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
*
* Git passes 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
*/
// 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() {
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`)
const connector = new TopicConnector({ timeout: 15000 })
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)
// This ensures '\n' precedes 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.
// This 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 the libudx double-free.
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)
})