/**
* 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()
}
}