OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / cli / commands / remote.js

244 lines · javascript ref:
/**
 * pgh remote commands
 *
 * Petname management for pear-git servers.
 * Stored locally in ~/.config/pear-git/pgh.json under "remotes".
 *
 * Subcommands: add, rm, list, rename, migrate-origins
 */

import fs from 'bare-fs'
import path from 'bare-path'
import { table, jsonOut, log, error } from '../output.js'
import { loadConfig, saveConfig } from '../config.js'
import { gitExec } from '../git.js'

export async function remoteCommand (client, sub, args, flags) {
  switch (sub) {
    case 'add':
      return remoteAdd(args[0], args[1], flags)
    case 'rm':
    case 'remove':
      return remoteRm(args[0], flags)
    case 'list':
    case 'ls':
      return remoteList(flags)
    case 'rename':
      return remoteRename(args[0], args[1], flags)
    case 'migrate-origins':
      return remoteMigrateOrigins(args[0], flags)
    default:
      remoteHelp()
  }
}

function remoteAdd (name, key, flags) {
  if (!name || !key) error('Usage: pgh remote add <name> <key>')

  const cfg = loadConfig()
  if (!cfg.remotes) cfg.remotes = {}

  if (cfg.remotes[name]) {
    error(`Remote "${name}" already exists. Use "pgh remote rm ${name}" first.`)
  }

  cfg.remotes[name] = key
  saveConfig(cfg)
  log(`Added remote "${name}" → ${key.slice(0, 16)}...`)
}

function remoteRm (name, flags) {
  if (!name) error('Usage: pgh remote rm <name>')

  const cfg = loadConfig()
  if (!cfg.remotes || !cfg.remotes[name]) {
    error(`Remote "${name}" not found.`)
  }

  delete cfg.remotes[name]
  saveConfig(cfg)
  log(`Removed remote "${name}".`)
}

function remoteList (flags) {
  const cfg = loadConfig()
  const remotes = cfg.remotes || {}
  const entries = Object.entries(remotes)

  if (flags.json) {
    jsonOut(remotes)
    return
  }

  if (entries.length === 0) {
    log('No remotes configured.')
    log('Add one: pgh remote add <name> <server-key>')
    return
  }

  table(entries.map(([name, key]) => ({
    name,
    key: key.length > 40 ? key.slice(0, 16) + '...' + key.slice(-8) : key
  })), [
    { key: 'name', label: 'NAME', width: 20 },
    { key: 'key', label: 'KEY', width: 30 }
  ])
}

function remoteRename (oldName, newName, flags) {
  if (!oldName || !newName) error('Usage: pgh remote rename <old> <new>')

  const cfg = loadConfig()
  if (!cfg.remotes || !cfg.remotes[oldName]) {
    error(`Remote "${oldName}" not found.`)
  }
  if (cfg.remotes[newName]) {
    error(`Remote "${newName}" already exists.`)
  }

  cfg.remotes[newName] = cfg.remotes[oldName]
  delete cfg.remotes[oldName]
  saveConfig(cfg)
  log(`Renamed remote "${oldName}" → "${newName}".`)
}

/**
 * Bulk-update git remote URLs across working directories.
 * Scans for .git repos, finds remotes matching --from prefix, replaces with --to prefix.
 *
 * Usage: pgh remote migrate-origins <dir> --from <prefix> --to <prefix> [--dry-run]
 */
async function remoteMigrateOrigins (dir, flags) {
  const from = flags.from
  const to = flags.to
  const dryRun = flags['dry-run'] || flags.dryRun

  if (!dir || !from || !to) {
    error('Usage: pgh remote migrate-origins <dir> --from <prefix> --to <prefix> [--dry-run]')
    return
  }

  dir = path.resolve(dir)

  log(`Scanning ${dir} for git repos...`)

  const gitDirs = await findGitRepos(fs, path, dir, 5)
  let updated = 0
  let skipped = 0
  let errors = 0

  for (const repoDir of gitDirs) {
    try {
      const output = await gitExec(['remote', '-v'], repoDir)
      const lines = output.split('\n')
      const seen = new Set() // avoid duplicating fetch/push remotes

      let repoHadMatch = false
      for (const line of lines) {
        if (!line.trim()) continue
        const parts = line.split(/\s+/)
        const remoteName = parts[0]
        const url = parts[1]

        if (!url || !url.startsWith(from) || seen.has(remoteName)) continue
        seen.add(remoteName)

        // Compute new URL: replace prefix, keep the suffix (e.g., /repo-name)
        const suffix = url.slice(from.length)
        const newUrl = to + suffix

        const relPath = path.relative(dir, repoDir) || '.'
        if (dryRun) {
          log(`  ${relPath}: ${remoteName} ${url} → ${newUrl}`)
        } else {
          await gitExec(['remote', 'set-url', remoteName, newUrl], repoDir)
          log(`  ${relPath}: ${remoteName} ${url} → ${newUrl}`)
        }
        repoHadMatch = true
        updated++
      }

      if (!repoHadMatch) skipped++
    } catch (err) {
      errors++
    }
  }

  if (dryRun) {
    log(`\nDry run: would update ${updated} remotes in ${gitDirs.length} repos (${skipped} skipped)`)
  } else {
    log(`\n\u2713 Updated ${updated} remotes in ${gitDirs.length - skipped} repos (${skipped} skipped${errors > 0 ? `, ${errors} errors` : ''})`)
  }
}

/**
 * Recursively find directories containing .git/ (max depth)
 */
async function findGitRepos (fs, path, dir, maxDepth, depth = 0) {
  if (depth > maxDepth) return []
  const results = []

  let entries
  try {
    entries = await fs.promises.readdir(dir)
  } catch {
    return results
  }

  // Check if this dir is a git repo
  if (entries.includes('.git')) {
    results.push(dir)
    return results // Don't recurse into nested git repos
  }

  // Recurse into subdirectories
  for (const entry of entries) {
    if (entry === 'node_modules' || entry === '.git' || entry.startsWith('.')) continue
    const fullPath = path.join(dir, entry)
    try {
      const stat = await fs.promises.stat(fullPath)
      if (stat.isDirectory()) {
        const subResults = await findGitRepos(fs, path, fullPath, maxDepth, depth + 1)
        results.push(...subResults)
      }
    } catch {
      // Skip inaccessible dirs
    }
  }

  return results
}

export function remoteHelp () {
  log(`Usage: pgh remote <subcommand> [args]

Subcommands:
  add <name> <key>                 Add a named remote (petname)
  rm <name>                        Remove a remote
  list                             List all remotes
  rename <old> <new>               Rename a remote
  migrate-origins <dir> [flags]    Bulk-update git remote URLs

Migrate-Origins Flags:
  --from <prefix>    URL prefix to match (required)
  --to <prefix>      Replacement URL prefix (required)
  --dry-run          Preview changes without applying

Examples:
  pgh remote add tabertron abc123def456...
  pgh remote list
  pgh remote rename tabertron myserver

  # Bulk-update origins after server key change
  pgh remote migrate-origins ~/projects \\
    --from punch://3cf3266a --to punch://abc123newkey

  # Switch from raw key to petname
  pgh remote migrate-origins ~/projects \\
    --from punch://3cf3266a --to pear://tabertron

  # Preview first
  pgh remote migrate-origins ~/projects \\
    --from punch://oldKey --to punch://newKey --dry-run`)
}