import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { GitSyncClientManager } from '../../lib/p2p/sync/git-sync-client-manager.js'
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
test('constructor: sets defaults', (t) => {
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
t.is(mgr.reposDir, '/tmp/repos')
t.is(mgr._activeSyncs.size, 0)
t.is(mgr._initialSyncDone.size, 0)
t.is(mgr._destroyed, false)
})
// ---------------------------------------------------------------------------
// handleRefUpdate: compares refs and triggers sync when different
// ---------------------------------------------------------------------------
test('handleRefUpdate triggers sync when refs differ', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
// Mock _readRefs to return local refs that differ from remote
mgr._readRefs = async (repoPath) => {
t.is(repoPath, '/tmp/repos/my-app.git', 'reads correct repo path')
return {
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'old-sha-111'
}
}
// Mock _syncRepo to verify it's called with correct args
let syncCalled = false
mgr._syncRepo = async (repoName, repoPath, peerKey) => {
syncCalled = true
t.is(repoName, 'my-app')
t.is(repoPath, '/tmp/repos/my-app.git')
t.is(peerKey, 'aabb'.repeat(16))
}
await mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'new-sha-222'
}, 'aabb'.repeat(16))
// Wait for async sync to complete
if (mgr._activeSyncs.has('my-app')) {
await mgr._activeSyncs.get('my-app')
}
t.ok(syncCalled, '_syncRepo was called')
t.is(mgr._activeSyncs.size, 0, 'active sync cleaned up')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: skips when already synced
// ---------------------------------------------------------------------------
test('handleRefUpdate skips when refs match', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
// Mock _readRefs to return refs that match the remote
mgr._readRefs = async () => ({
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'same-sha-999'
})
let syncCalled = false
mgr._syncRepo = async () => {
syncCalled = true
}
await mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'same-sha-999'
}, 'ccdd'.repeat(16))
t.absent(syncCalled, '_syncRepo was NOT called when synced')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: skips for unknown local repo
// ---------------------------------------------------------------------------
test('handleRefUpdate skips for unknown local repo', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
// Mock _readRefs to throw ENOENT (repo doesn't exist locally)
mgr._readRefs = async () => {
const err = new Error('ENOENT: no such file')
err.code = 'ENOENT'
throw err
}
let syncCalled = false
mgr._syncRepo = async () => {
syncCalled = true
}
await mgr.handleRefUpdate('nonexistent-repo', {
'refs/heads/main': 'abc123'
}, 'eeff'.repeat(16))
t.absent(syncCalled, '_syncRepo was NOT called for missing repo')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: deduplicates concurrent syncs for same repo
// ---------------------------------------------------------------------------
test('handleRefUpdate deduplicates concurrent syncs', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
mgr._readRefs = async () => ({
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'old-sha-111'
})
let syncCount = 0
let resolveSync
const syncStarted = new Promise((r) => { resolveSync = r })
mgr._syncRepo = async () => {
syncCount++
resolveSync()
// Simulate slow sync
await new Promise(r => setTimeout(r, 100))
}
const peerKey = '1122'.repeat(16)
const remoteRefs = { 'refs/heads/main': 'new-sha-222' }
// Fire two ref-updates for the same repo concurrently
mgr.handleRefUpdate('my-app', remoteRefs, peerKey)
await syncStarted // wait for first to start
mgr.handleRefUpdate('my-app', remoteRefs, peerKey) // should be deduped
// Wait for active sync to complete
if (mgr._activeSyncs.has('my-app')) {
await mgr._activeSyncs.get('my-app')
}
t.is(syncCount, 1, '_syncRepo called only once (second was deduped)')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: handles sync errors gracefully
// ---------------------------------------------------------------------------
test('handleRefUpdate handles sync errors gracefully', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
mgr._readRefs = async () => ({
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'old-sha-111'
})
mgr._syncRepo = async () => {
throw new Error('git fetch exit 128: remote hung up')
}
// Should not throw
await mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'new-sha-222'
}, 'aabb'.repeat(16))
// Wait for sync promise to settle
await new Promise(r => setTimeout(r, 50))
t.is(mgr._activeSyncs.size, 0, 'active sync cleaned up after error')
t.pass('error handled without throwing')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: detects added refs
// ---------------------------------------------------------------------------
test('handleRefUpdate detects added refs', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
// Local has only main, remote has main + feature branch
mgr._readRefs = async () => ({
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'sha-aaa'
})
let syncCalled = false
mgr._syncRepo = async () => {
syncCalled = true
}
await mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'sha-aaa',
'refs/heads/feature': 'sha-bbb'
}, 'ccdd'.repeat(16))
if (mgr._activeSyncs.has('my-app')) {
await mgr._activeSyncs.get('my-app')
}
t.ok(syncCalled, '_syncRepo called for added ref')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleRefUpdate: skips when destroyed
// ---------------------------------------------------------------------------
test('handleRefUpdate does nothing when destroyed', async (t) => {
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
await mgr.destroy()
let readCalled = false
mgr._readRefs = async () => {
readCalled = true
return {}
}
await mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'abc123'
}, 'aabb'.repeat(16))
t.absent(readCalled, '_readRefs was NOT called after destroy')
})
// ---------------------------------------------------------------------------
// destroy cleans up
// ---------------------------------------------------------------------------
test('destroy cleans up all state', async (t) => {
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
await mgr.destroy()
t.is(mgr._destroyed, true, 'marked as destroyed')
t.is(mgr._activeSyncs.size, 0, 'active syncs cleared')
})
test('destroy waits for active syncs', async (t) => {
t.timeout(10000)
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
mgr._readRefs = async () => ({
HEAD: 'ref: refs/heads/main',
'refs/heads/main': 'old-sha'
})
let syncFinished = false
let resolveSyncStarted
const syncStarted = new Promise((r) => { resolveSyncStarted = r })
mgr._syncRepo = async () => {
resolveSyncStarted()
await new Promise(r => setTimeout(r, 100))
syncFinished = true
}
// Start a sync (don't await — it runs in background)
mgr.handleRefUpdate('my-app', {
'refs/heads/main': 'new-sha'
}, 'aabb'.repeat(16))
// Wait for the sync to actually start before destroying
await syncStarted
// Destroy should wait for active sync to finish
await mgr.destroy()
t.ok(syncFinished, 'sync completed before destroy resolved')
t.is(mgr._activeSyncs.size, 0, 'active syncs cleared')
})
// ---------------------------------------------------------------------------
// _buildManifest: reads all repos
// ---------------------------------------------------------------------------
test('_buildManifest reads all repos from reposDir', async (t) => {
t.timeout(10000)
const tmp = path.join(os.tmpdir(), 'test-manifest-' + Date.now())
await fs.promises.mkdir(tmp, { recursive: true })
// Create two fake bare repos
const repoA = path.join(tmp, 'repo-a.git')
const repoB = path.join(tmp, 'repo-b.git')
await fs.promises.mkdir(path.join(repoA, 'refs', 'heads'), { recursive: true })
await fs.promises.mkdir(path.join(repoB, 'refs', 'heads'), { recursive: true })
await fs.promises.writeFile(path.join(repoA, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(path.join(repoB, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(
path.join(repoA, 'refs', 'heads', 'main'),
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'
)
await fs.promises.writeFile(
path.join(repoB, 'refs', 'heads', 'main'),
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'
)
// Also create a non-.git directory (should be skipped)
await fs.promises.mkdir(path.join(tmp, 'not-a-repo'), { recursive: true })
const mgr = new GitSyncClientManager({ reposDir: tmp })
const manifest = await mgr._buildManifest()
t.is(Object.keys(manifest).length, 2, 'has 2 repos')
t.is(manifest['repo-a']['refs/heads/main'], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
t.is(manifest['repo-b']['refs/heads/main'], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
t.absent(manifest['not-a-repo'], 'non-.git dirs skipped')
await fs.promises.rm(tmp, { recursive: true })
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// _buildManifest: filters out HEAD and non-hash refs
// ---------------------------------------------------------------------------
test('_buildManifest filters out HEAD and symbolic refs', async (t) => {
t.timeout(10000)
const tmp = path.join(os.tmpdir(), 'test-manifest-filter-' + Date.now())
const repo = path.join(tmp, 'test-repo.git')
await fs.promises.mkdir(path.join(repo, 'refs', 'heads'), { recursive: true })
await fs.promises.writeFile(path.join(repo, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(
path.join(repo, 'refs', 'heads', 'main'),
'cccccccccccccccccccccccccccccccccccccccc\n'
)
const mgr = new GitSyncClientManager({ reposDir: tmp })
const manifest = await mgr._buildManifest()
t.absent(manifest['test-repo'].HEAD, 'HEAD excluded')
t.is(manifest['test-repo']['refs/heads/main'], 'cccccccccccccccccccccccccccccccccccccccc')
await fs.promises.rm(tmp, { recursive: true })
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleFullSyncManifest: compares and triggers sync for differing repos
// ---------------------------------------------------------------------------
test('handleFullSyncManifest syncs differing repos and verifies', async (t) => {
t.timeout(10000)
const tmp = path.join(os.tmpdir(), 'test-full-sync-' + Date.now())
const repoA = path.join(tmp, 'repo-a.git')
const repoB = path.join(tmp, 'repo-b.git')
await fs.promises.mkdir(path.join(repoA, 'refs', 'heads'), { recursive: true })
await fs.promises.mkdir(path.join(repoB, 'refs', 'heads'), { recursive: true })
await fs.promises.writeFile(path.join(repoA, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(path.join(repoB, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(
path.join(repoA, 'refs', 'heads', 'main'),
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'
)
await fs.promises.writeFile(
path.join(repoB, 'refs', 'heads', 'main'),
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'
)
const mgr = new GitSyncClientManager({ reposDir: tmp })
// Track which repos _syncRepo is called for
const syncedRepos = []
mgr._syncRepo = async (repoName, repoPath, peerKey) => {
syncedRepos.push(repoName)
// Simulate successful sync by updating local refs to match remote
await fs.promises.mkdir(path.join(repoPath, 'refs', 'heads'), { recursive: true })
if (!await fs.promises.stat(path.join(repoPath, 'HEAD')).catch(() => null)) {
await fs.promises.writeFile(path.join(repoPath, 'HEAD'), 'ref: refs/heads/main\n')
}
const remoteHash = remoteManifest[repoName]['refs/heads/main']
await fs.promises.writeFile(path.join(repoPath, 'refs', 'heads', 'main'), remoteHash + '\n')
}
const remoteManifest = {
'repo-a': { 'refs/heads/main': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, // same
'repo-b': { 'refs/heads/main': 'dddddddddddddddddddddddddddddddddddddddd' }, // different
'repo-c': { 'refs/heads/main': 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' } // missing locally
}
const results = await mgr.handleFullSyncManifest(remoteManifest, 'aabb'.repeat(16))
t.is(results.synced, 3, '3 repos synced (repo-a already synced + repo-b + repo-c fetched)')
t.is(results.missing, 0, '0 missing (mirror creates them)')
t.is(results.failed, 0, 'no failures')
t.alike(syncedRepos, ['repo-b', 'repo-c'], 'repo-b and repo-c were fetched')
await fs.promises.rm(tmp, { recursive: true })
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleFullSyncManifest: handles sync errors gracefully
// ---------------------------------------------------------------------------
test('handleFullSyncManifest handles sync errors without crashing', async (t) => {
t.timeout(10000)
const tmp = path.join(os.tmpdir(), 'test-full-sync-err-' + Date.now())
const repo = path.join(tmp, 'bad-repo.git')
await fs.promises.mkdir(path.join(repo, 'refs', 'heads'), { recursive: true })
await fs.promises.writeFile(path.join(repo, 'HEAD'), 'ref: refs/heads/main\n')
await fs.promises.writeFile(
path.join(repo, 'refs', 'heads', 'main'),
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'
)
const mgr = new GitSyncClientManager({ reposDir: tmp })
mgr._syncRepo = async () => {
throw new Error('git fetch exit 128: remote hung up')
}
const results = await mgr.handleFullSyncManifest({
'bad-repo': { 'refs/heads/main': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }
}, 'ccdd'.repeat(16))
t.is(results.failed, 1, '1 repo failed')
t.is(results.errors.length, 1)
t.is(results.errors[0].repo, 'bad-repo')
await fs.promises.rm(tmp, { recursive: true })
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// handleFullSyncManifest: missing repos are logged, not errored
// ---------------------------------------------------------------------------
test('handleFullSyncManifest creates and syncs missing repos (mirror mode)', async (t) => {
t.timeout(10000)
const tmp = path.join(os.tmpdir(), 'test-full-sync-missing-' + Date.now())
await fs.promises.mkdir(tmp, { recursive: true })
// No repos exist locally
const mgr = new GitSyncClientManager({ reposDir: tmp })
// Mock _syncRepo to succeed without actually fetching
const synced = []
mgr._syncRepo = async (repo) => { synced.push(repo) }
const results = await mgr.handleFullSyncManifest({
'remote-only-a': { 'refs/heads/main': 'aaa' },
'remote-only-b': { 'refs/heads/main': 'bbb' }
}, 'eeff'.repeat(16))
// Repos should have been created and sync attempted
t.ok(synced.includes('remote-only-a'), 'synced remote-only-a')
t.ok(synced.includes('remote-only-b'), 'synced remote-only-b')
// Bare repos should exist on disk
const entries = await fs.promises.readdir(tmp)
t.ok(entries.includes('remote-only-a.git'), 'created remote-only-a.git')
t.ok(entries.includes('remote-only-b.git'), 'created remote-only-b.git')
await fs.promises.rm(tmp, { recursive: true })
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// _initialSyncDone prevents re-triggering
// ---------------------------------------------------------------------------
test('_initialSyncDone flag prevents duplicate full syncs', async (t) => {
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
const peerKey = 'aabb'.repeat(16)
// Simulate initial sync already done
mgr._initialSyncDone.set(peerKey, true)
let sendCalled = false
const mockClient = {
sendFullSyncRequest () {
sendCalled = true
}
}
await mgr.initiateFullSync(mockClient, peerKey)
t.absent(sendCalled, 'sendFullSyncRequest was NOT called (already done)')
await mgr.destroy()
})
// ---------------------------------------------------------------------------
// destroy clears _initialSyncDone
// ---------------------------------------------------------------------------
test('destroy clears _initialSyncDone', async (t) => {
const mgr = new GitSyncClientManager({
reposDir: '/tmp/repos'
})
mgr._initialSyncDone.set('some-peer', true)
await mgr.destroy()
t.is(mgr._initialSyncDone.size, 0, '_initialSyncDone cleared')
})