OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / p2p / sync / git-sync-client-manager.js

520 lines · javascript ref:
/**
 * GitSyncClientManager — Receives ref-update notifications and syncs changed repos
 *
 * Works alongside GitSyncManager (server side). When a federation peer
 * broadcasts a ref-update, the client manager:
 *
 *   1. Reads local refs for the repo
 *   2. Diffs against the remote refs
 *   3. If not in sync, runs a targeted `git fetch` via punch:// URL
 *
 * On initial connection, performs a full manifest exchange to sync all repos:
 *
 *   1. Reads all local repos' refs
 *   2. Sends full-sync-request with local manifest
 *   3. Receives full-sync-manifest from peer
 *   4. Compares all repos, syncs what's different (sequentially)
 *   5. Verifies mirrors match after sync
 *
 * Usage:
 *   const clientMgr = new GitSyncClientManager({ reposDir })
 *
 *   // In GitSyncManager's onRefUpdate callback:
 *   clientMgr.handleRefUpdate('my-repo', remoteRefs, peerKey)
 *
 *   // In pear-git-server.js _initBackground:
 *   this.gitSyncClientManager = new GitSyncClientManager({
 *     reposDir: this.repoRegistry.gitReposPath
 *   })
 */

import path from 'bare-path'
import fs from 'bare-fs'
import { spawn } from 'bare-subprocess'
import { readRefs } from '../../git/ref-reader.js'
import { diffRefs } from '../../git/ref-diff.js'

export class GitSyncClientManager {
  constructor (opts = {}) {
    this.reposDir = opts.reposDir
    this._repoRegistry = opts.repoRegistry || null // For auto-installing hooks on new repos
    this._repos = opts.repos || null // In-memory repos array (server.repos) — updated after sync
    this._topicManager = opts.topicManager || null // Announce new repos on DHT at runtime
    this.linkedRepos = opts.linkedRepos || null // Map<repoName, LinkedRepo> for issue Autobase keys
    this.appDir = opts.appDir || null // Installed app directory (for self-update)
    this.appRepoName = opts.appRepoName || null // This app's repo name (e.g., 'pear-git')
    this.appBranch = opts.appBranch || 'master' // Branch to pull for self-update
    this.serverKey = opts.serverKey || null // This server's key — used to skip self-sync
    this.onIssueKeyReceived = opts.onIssueKeyReceived || null // callback(repoName, remoteKey, peerServerKey)
    this._activeSyncs = new Map() // repoName -> Promise (prevents duplicate syncs)
    this._activeManifestSync = null // Promise — prevents duplicate full-sync-manifest handling
    this._initialSyncDone = new Map() // peerKey -> true (prevents re-triggering per peer)
    this._destroyed = false
  }

  /**
   * Handle a ref-update notification from a federation peer.
   *
   * Compares remote refs against local refs and triggers a targeted
   * `git fetch` if the repo is out of sync. Deduplicates concurrent
   * requests for the same repo.
   *
   * @param {string} repoName — repository name (without .git suffix)
   * @param {object} remoteRefs — remote ref map { refName: sha, ... }
   * @param {string} peerKey — hex public key of the peer that sent the update
   */
  async handleRefUpdate (repoName, remoteRefs, peerKey, syncServer) {
    if (this._destroyed) return
    if (this.serverKey && peerKey === this.serverKey) return // self-sync guard

    // Skip if already syncing this repo
    if (this._activeSyncs.has(repoName)) {
      return
    }

    const repoPath = path.join(this.reposDir, repoName + '.git')

    // Read local refs
    let localRefs
    try {
      localRefs = await this._readRefs(repoPath)
    } catch (err) {
      if (err.code === 'ENOENT') {
        // Repo exists on remote but not locally — create it and sync
        try {
          await this._initBareRepo(repoPath)
          console.log(`[GitSyncClient] Created missing repo: ${repoName}`)
          localRefs = {}
        } catch (initErr) {
          console.error(`[GitSyncClient] Failed to create repo ${repoName}: ${initErr.message}`)
          return
        }
      } else {
        console.error(`[GitSyncClient] Error reading refs for ${repoName}: ${err.message}`)
        return
      }
    }

    // Compare local vs remote
    const diff = diffRefs(localRefs, remoteRefs)
    if (diff.synced) return

    // Refs differ — trigger targeted fetch
    const summary = []
    if (diff.changed.length > 0) summary.push(`${diff.changed.length} changed`)
    if (diff.added.length > 0) summary.push(`${diff.added.length} added`)
    if (diff.removed.length > 0) summary.push(`${diff.removed.length} removed`)
    console.log(`[GitSyncClient] Syncing ${repoName} from ${peerKey.slice(0, 8)} (${summary.join(', ')})`)

    const syncPromise = this._syncRepo(repoName, repoPath, peerKey, syncServer)
      .then(() => {
        console.log(`[GitSyncClient] Synced ${repoName} from ${peerKey.slice(0, 8)}`)
      })
      .catch(err => {
        console.error(`[GitSyncClient] Failed to sync ${repoName} from ${peerKey.slice(0, 8)}: ${err.message}`)
      })
      .finally(() => {
        this._activeSyncs.delete(repoName)
      })

    this._activeSyncs.set(repoName, syncPromise)
  }

  /**
   * Initiate a full sync with a peer.
   *
   * Builds a manifest of all local repos and their refs, then sends
   * a full-sync-request. The peer responds with a full-sync-manifest.
   * Only fires once per peer connection.
   *
   * @param {object} client — GitSyncClient instance
   * @param {string} peerKey — hex public key of the peer
   */
  async initiateFullSync (client, peerKey) {
    if (this._destroyed) return
    if (this._initialSyncDone.has(peerKey)) return

    this._initialSyncDone.set(peerKey, true)

    try {
      const localManifest = await this._buildManifest()
      console.log(`[GitSync] Sending full-sync-request to ${peerKey.slice(0, 8)} with ${Object.keys(localManifest).length} repos`)
      client.sendFullSyncRequest(localManifest)
    } catch (err) {
      console.error(`[GitSync] Failed to initiate full sync with ${peerKey.slice(0, 8)}: ${err.message}`)
    }
  }

  /**
   * Handle a full-sync-manifest response from a peer.
   *
   * Compares each remote repo against local repos, syncs repos that
   * differ (sequentially to prevent connection saturation), then
   * verifies that all repos match.
   *
   * @param {object} remoteManifest — { repoName: { refName: sha, ... }, ... }
   * @param {string} peerKey — hex public key of the peer
   * @returns {Promise<object>} sync results { synced, missing, failed, errors }
   */
  async handleFullSyncManifest (remoteManifest, peerKey) {
    if (this._destroyed) return null

    // Deduplicate: both sides exchange manifests, causing two concurrent calls.
    // Wait for any in-flight sync to finish before starting a new one.
    if (this._activeManifestSync) {
      await this._activeManifestSync.catch(() => {})
    }

    const promise = this._doFullSync(remoteManifest, peerKey)
    this._activeManifestSync = promise
    try {
      return await promise
    } finally {
      if (this._activeManifestSync === promise) {
        this._activeManifestSync = null
      }
    }
  }

  async _doFullSync (remoteManifest, peerKey) {
    const localManifest = await this._buildManifest()
    const remoteRepoCount = Object.keys(remoteManifest).length

    console.log(`[GitSync] Received full-sync-manifest from ${peerKey.slice(0, 8)} with ${remoteRepoCount} repos`)

    const results = { synced: 0, missing: 0, failed: 0, errors: [] }

    // Compare each remote repo against local — sync sequentially
    for (const [repo, remoteRepoData] of Object.entries(remoteManifest)) {
      // Backward compat: old format is { ref: sha }, new format is { refs: { ref: sha }, issueKey }
      const remoteRefs = remoteRepoData.refs || remoteRepoData
      const remoteIssueKey = remoteRepoData.issueKey || null

      const localRepoData = localManifest[repo]
      let localRefs = localRepoData ? (localRepoData.refs || localRepoData) : null

      if (!localRefs) {
        // Repo exists on remote but not locally — create it (mirror mode)
        const repoPath = path.join(this.reposDir, repo + '.git')
        try {
          await this._initBareRepo(repoPath)
          console.log(`[GitSync] Created missing repo: ${repo}`)
          localRefs = {}
        } catch (err) {
          console.error(`[GitSync] Failed to create repo ${repo}: ${err.message}`)
          results.missing++
          continue
        }
      }

      const diff = diffRefs(localRefs, remoteRefs)
      if (!diff.synced) {
        // Sync this repo sequentially
        const repoPath = path.join(this.reposDir, repo + '.git')
        try {
          await this._syncRepo(repo, repoPath, peerKey)
          results.synced++
        } catch (err) {
          results.failed++
          results.errors.push({ repo, error: err.message })
        }
      } else {
        results.synced++
      }

      // Federate issue Autobase keys (for mirror peers)
      if (remoteIssueKey && this.onIssueKeyReceived) {
        try {
          await this.onIssueKeyReceived(repo, remoteIssueKey, peerKey)
        } catch (err) {
          console.error(`[GitSync] Issue key adoption failed for ${repo}: ${err.message}`)
        }
      }
    }

    // Mirror verification: re-read local refs and compare
    const verifyManifest = await this._buildManifest()
    let verified = 0
    const failedRepos = []
    const missingRepos = []

    for (const [repo, remoteRepoData] of Object.entries(remoteManifest)) {
      const remoteRefs = remoteRepoData.refs || remoteRepoData
      const localRepoData = verifyManifest[repo]
      const localRefs = localRepoData ? (localRepoData.refs || localRepoData) : null
      if (!localRefs) {
        missingRepos.push(repo)
        continue
      }
      const diff = diffRefs(localRefs, remoteRefs)
      if (diff.synced) {
        verified++
      } else {
        failedRepos.push(repo)
      }
    }

    // Log mirror status
    if (verified === remoteRepoCount) {
      console.log(`[GitSync] Mirror verified: ${verified}/${remoteRepoCount} repos synced`)
    } else {
      console.log(`[GitSync] Mirror verified: ${verified}/${remoteRepoCount} repos synced`)
      if (failedRepos.length > 0) {
        console.log(`[GitSync] Failed: ${failedRepos.join(', ')}`)
      }
      if (missingRepos.length > 0) {
        console.log(`[GitSync] Missing locally: ${missingRepos.join(', ')}`)
      }
    }

    return results
  }

  /**
   * Build a manifest of all local repos and their refs.
   * Scans reposDir for .git directories and reads refs from each.
   *
   * @returns {Promise<Object>} { repoName: { refName: sha, ... }, ... }
   */
  async _buildManifest () {
    const manifest = {}
    try {
      const entries = await fs.promises.readdir(this.reposDir)
      for (const entry of entries) {
        if (!entry.endsWith('.git')) continue
        const repoPath = path.join(this.reposDir, entry)
        // Skip repos with .pear-git-ignore marker
        const ignored = await fs.promises.stat(path.join(repoPath, '.pear-git-ignore')).catch(() => null)
        if (ignored) continue
        const repoName = entry.replace(/\.git$/, '')
        try {
          const refs = await this._readRefs(repoPath)
          const hashRefs = {}
          for (const [ref, val] of Object.entries(refs)) {
            if (ref === 'HEAD') continue
            if (/^[0-9a-f]{40}$/.test(val)) hashRefs[ref] = val
          }
          const linked = this.linkedRepos ? this.linkedRepos.get(repoName) : null
          manifest[repoName] = {
            refs: hashRefs,
            issueKey: linked ? linked.bootstrapKeyHex : null
          }
        } catch {}
      }
    } catch {}
    return manifest
  }

  /**
   * Run a targeted `git fetch` for a single repo from a specific peer.
   *
   * Uses punch:// URL (P2P via Hyperswarm) to fetch only the changed repo.
   * This is the same mechanism as ServerSync._syncRepo but triggered
   * on-demand instead of periodically.
   *
   * @param {string} repoName — repository name
   * @param {string} repoPath — absolute path to the bare git repo
   * @param {string} peerKey — hex public key of the peer to fetch from
   * @returns {Promise<void>}
   */
  async _syncRepo (repoName, repoPath, peerKey, syncServer) {
    const fetchUrl = `punch://${peerKey}/${repoName}`
    console.log(`[GitSync] Fetching ${repoName} from ${peerKey.slice(0, 8)}...`)

    await new Promise((resolve, reject) => {
      let done = false
      // Full fetch — federation mirrors need all objects to serve clients.
      // Large transfers may take minutes but won't be killed (no hard timeout).
      const child = spawn('git', [
        '-C', repoPath, 'fetch', '--progress',
        fetchUrl, 'refs/*:refs/*', '--no-tags'
      ], { detached: true })

      // No hard timeout — large repos (1+ GB) can take minutes.
      // The P2P transport (SecretStream) has its own inactivity timeout
      // (45s) which handles hung connections. We just need to wait for
      // the transfer to complete, however long it takes.
      child.on('exit', (code) => {
        if (done) return
        done = true
        if (code === 0) resolve()
        else {
          const stderr = stderrBuf.join('')
          reject(new Error(`git fetch exit ${code}: ${stderr.trim()}`))
        }
      })
      child.on('error', (err) => {
        if (done) return
        done = true
        reject(err)
      })

      const stderrBuf = []
      let bytesReceived = 0
      let lastLog = Date.now()
      child.stderr.on('data', (d) => {
        stderrBuf.push(d.toString())
        bytesReceived += d.length
      })
      child.stdout.on('data', (d) => {
        bytesReceived += d.length
        const now = Date.now()
        if (now - lastLog > 5000) { // log every 5s
          const mb = (bytesReceived / 1024 / 1024).toFixed(1)
          console.log(`[GitSync] ${repoName}: ${mb} MB received`)
          lastLog = now
        }
      })
    })

    // Refresh repo metadata (commits, branches, lastCommit) so the UI stays current
    this._refreshRepoMetadata(repoName)

    // After successful sync: if this repo is pear-git itself, self-update
    if (this.appDir && this.appRepoName && repoName === this.appRepoName) {
      await this._selfUpdate(repoPath)
    }
  }

  /**
   * Update the installed app copy from the local bare repo's release branch.
   * Then exit with code 75 (EX_TEMPFAIL) to signal the process wrapper to restart.
   * No network needed — the bare repo was just updated by _syncRepo.
   */
  async _selfUpdate (bareRepoPath) {
    if (!this.appDir) return

    // Check if master branch has new commits.
    // pear-git runs from source (bare), not staged/released Pear app.
    const branch = this.appBranch || 'master'
    let localHash, remoteHash
    try {
      localHash = (await this._exec('git', ['-C', this.appDir, 'rev-parse', 'HEAD'])).trim()
      await this._exec('git', ['-C', this.appDir, 'fetch', bareRepoPath, branch])
      remoteHash = (await this._exec('git', ['-C', this.appDir, 'rev-parse', 'FETCH_HEAD'])).trim()
    } catch (err) {
      console.error('[GitSync] Self-update check failed:', err.message)
      return
    }

    if (localHash === remoteHash) return // Already up to date

    // Verify fetch is complete
    try {
      await this._exec('git', ['-C', bareRepoPath, 'fsck', '--no-dangling'])
    } catch (err) {
      console.error('[GitSync] Self-update aborted — integrity check failed:', err.message)
      return
    }

    // Apply update then exit with code 75 (EX_TEMPFAIL) to signal restart.
    // systemd/tmux/process wrapper picks up the new code on restart.
    console.log(`[GitSync] Self-update: ${localHash.slice(0, 8)} → ${remoteHash.slice(0, 8)}`)
    try {
      await this._exec('git', ['-C', this.appDir, 'reset', '--hard', 'FETCH_HEAD'])
      console.log('[GitSync] Code updated. Exiting for restart...')
      process.exit(75)
    } catch (err) {
      console.error('[GitSync] Self-update failed:', err.message)
    }
  }

  /**
   * Refresh repo metadata after sync (fire-and-forget).
   * Re-reads commits, branches, lastCommit from the git repo
   * and updates both the registry and the in-memory repos array.
   */
  _refreshRepoMetadata (repoName) {
    if (!this._repoRegistry) return
    this._repoRegistry.refreshRepoMetadata(repoName).then(updated => {
      if (!updated || !this._repos) return
      const idx = this._repos.findIndex(r => r.name === repoName)
      if (idx >= 0) this._repos[idx] = updated
      else this._repos.push(updated)
    }).catch(() => {})
  }

  _exec (cmd, args) {
    return new Promise((resolve, reject) => {
      const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
      const chunks = []
      proc.stdout.on('data', (c) => chunks.push(c))
      proc.on('exit', (code) => {
        if (code === 0) resolve(Buffer.concat(chunks).toString('utf-8'))
        else reject(new Error(`${cmd} exited with code ${code}`))
      })
      proc.on('error', reject)
    })
  }

  /**
   * Create a bare git repo for a missing mirror repo.
   *
   * @param {string} repoPath — path to create the bare repo at
   * @returns {Promise<void>}
   */
  async _initBareRepo (repoPath) {
    await new Promise((resolve, reject) => {
      const child = spawn('git', ['init', '--bare', repoPath])
      child.on('exit', (code) => {
        if (code === 0) resolve()
        else reject(new Error(`git init --bare exited with code ${code}`))
      })
      child.on('error', reject)
    })

    // Auto-install post-receive hook and register with server
    if (this._repoRegistry) {
      await this._repoRegistry.ensurePostReceiveHook(repoPath)

      // Register so the repo appears in the web UI and API immediately
      const repoName = path.basename(repoPath, '.git')
      await this._repoRegistry.register({
        name: repoName,
        path: repoPath,
        visibility: 'public'
      })
      console.log(`[GitSyncClient] Registered ${repoName} with server`)

      // Update in-memory repos list so it's immediately queryable
      if (this._repos) {
        const exists = this._repos.find(r => r.name === repoName)
        if (!exists) {
          this._repos.push({ name: repoName, path: repoPath, visibility: 'public' })
        }
      }

      // Announce on DHT so other peers can fetch via punch://
      if (this._topicManager) {
        await this._topicManager.announceRepo(repoName)
      }
    }
  }

  /**
   * Read refs from a bare git repository.
   * Wrapped in a method for testability (tests can override).
   *
   * @param {string} repoPath — path to the bare git repo
   * @returns {Promise<Object>} ref name -> SHA hash
   */
  async _readRefs (repoPath) {
    return readRefs(repoPath)
  }

  /**
   * Destroy the client manager: cancel pending syncs.
   */
  async destroy () {
    this._destroyed = true

    // Wait for active syncs to complete (they'll self-clean)
    if (this._activeSyncs.size > 0) {
      await Promise.allSettled(Array.from(this._activeSyncs.values()))
    }

    this._activeSyncs.clear()
    this._initialSyncDone.clear()
  }
}