OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / git-sync-client-manager.test.js

554 lines · javascript ref:
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')
})