OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-activity-cache.test.js

171 lines · javascript ref:
/**
 * repo-activity enrich cache: parallelism, disk persistence (restart
 * survival), and precise push invalidation.
 *
 * Covers the /repos landing-page perf fixes:
 *   #1 parallel enrichRepos, #2 persisted cache, #3 (pre-warm — exercised
 *   indirectly via the same code path), #4 invalidateRepoActivity.
 */

import test from 'brittle'
import { mkdtemp, rm, mkdir, writeFile, readFile } from '#node:fs/promises'
import { existsSync } from '#node:fs'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
// bare-subprocess exports spawn/spawnSync but NOT execFileSync, so the
// #node:child_process map cannot supply it under Bare — use the project's
// cross-runtime shim, same as lib/ does.
import { execFileSync } from '../lib/_compat-exec.js'
// `process` is NOT a global in Bare — it must be imported via the map.
import process from '#node:process'

function git (cwd, args, extraEnv = {}) {
  return execFileSync('git', args, {
    cwd,
    stdio: ['ignore', 'pipe', 'ignore'],
    env: {
      ...process.env,
      GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t',
      GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
      ...extraEnv
    }
  }).toString()
}

// Build a real bare repo with `n` commits, return its path. We build in a
// working clone then push into the bare repo so it has a proper history.
async function makeBareRepo (root, name, n) {
  const barePath = join(root, name + '.git')
  const workPath = join(root, name + '-work')
  await mkdir(barePath, { recursive: true })
  await mkdir(workPath, { recursive: true })
  git(barePath, ['init', '--bare', '-q'])
  git(workPath, ['init', '-q'])
  for (let i = 0; i < n; i++) {
    await writeFile(join(workPath, 'f'), 'v' + i)
    git(workPath, ['add', 'f'])
    git(workPath, ['commit', '-q', '-m', 'c' + i])
  }
  const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
  git(workPath, ['remote', 'add', 'origin', barePath])
  git(workPath, ['push', '-q', 'origin', branch])
  return barePath
}

function statusFor (names) {
  return { active: true, repos: names.map((name) => ({ name, serving: true })) }
}
function reposMapFor (root, names) {
  const m = new Map()
  for (const name of names) m.set(name, { path: join(root, name + '.git') })
  return m
}

// Fresh module instance (simulates a daemon restart: empty in-memory cache).
function freshModule (tag) {
  return import('../lib/services/repo-activity.js?fresh=' + tag)
}

test('enrichRepos: parallel, ordered, correct per-repo details', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'ra-basic-'))
  t.teardown(() => rm(root, { recursive: true, force: true }))

  const names = ['alpha', 'beta', 'gamma']
  const counts = { alpha: 3, beta: 1, gamma: 5 }
  for (const n of names) await makeBareRepo(root, n, counts[n])

  const mod = await freshModule('basic')
  const out = await mod.enrichRepos(statusFor(names), reposMapFor(root, names))

  t.is(out.length, 3, 'one record per repo')
  t.alike(out.map((r) => r.name), names, 'order preserved')
  for (const r of out) {
    t.is(r.commits, counts[r.name], r.name + ' commit count')
    t.ok(r.lastCommit && Number.isFinite(r.lastCommit.timestamp), r.name + ' has lastCommit')
    t.is(r.activity.length, 365, r.name + ' has 365 activity buckets')
    const sum = r.activity.reduce((a, b) => a + b, 0)
    t.is(sum, counts[r.name], r.name + ' activity sums to commit count (all recent)')
  }
})

test('cache persists to disk and survives a restart (state reset, both runtimes)', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'ra-persist-'))
  const storage = join(root, 'storage')
  await mkdir(storage, { recursive: true })
  t.teardown(() => rm(root, { recursive: true, force: true }))

  await makeBareRepo(root, 'repo', 4)
  const names = ['repo']

  // First "boot": configure, enrich, flush to disk.
  const first = await freshModule('persist-1')
  await first.configureRepoActivityCache(storage)
  const out1 = await first.enrichRepos(statusFor(names), reposMapFor(root, names))
  t.is(out1[0].commits, 4, 'computed real commit count')
  await first.flushRepoActivityCache()

  // The cache file exists and holds the entry.
  const cacheFile = join(storage, 'repo-activity-cache.json')
  const persisted = JSON.parse(await readFile(cacheFile, 'utf8'))
  t.ok(persisted.entries.repo, 'entry persisted for repo')
  t.is(persisted.entries.repo.data.commits, 4, 'persisted commit count')

  // Tamper the persisted value with a sentinel (same pack mtime) so a
  // cache HIT is provable: if the fresh instance returns the sentinel, it
  // read the disk cache instead of re-running git.
  persisted.entries.repo.data.commits = 99999
  await writeFile(cacheFile, JSON.stringify(persisted))

  // Second "boot". A fresh module import CANNOT model this under Bare:
  // `import(x?fresh=tag)` returns a distinct module under Node but the SAME
  // object under Bare, which resolves by realpath and ignores the query. So
  // the old `freshModule('persist-2')` handed back the first boot's instance,
  // still holding its populated cache and `loaded = true` — the disk-load path
  // never ran, and the sentinel could never be observed. Resetting the
  // process-local state exercises the same path in both runtimes.
  const second = first
  second.resetRepoActivityCacheForTests()
  await second.configureRepoActivityCache(storage)
  const out2 = await second.enrichRepos(statusFor(names), reposMapFor(root, names))
  t.is(out2[0].commits, 99999, 'cache HIT after restart — served sentinel, no git recompute')

  // #4: invalidate → next render recomputes (real value, not sentinel).
  second.invalidateRepoActivity('repo')
  const out3 = await second.enrichRepos(statusFor(names), reposMapFor(root, names))
  t.is(out3[0].commits, 4, 'invalidation forced a recompute')
})

test('#5 single-spawn: old commit still counted + lastCommit found (no recent activity); commit-graph written', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'ra-collapse-'))
  t.teardown(() => rm(root, { recursive: true, force: true }))

  // A repo whose only commit is >365 days old. The old 3-call impl caught
  // lastCommit via `log -1` while the separate `--since=365d` activity call
  // returned empty; the collapsed single-spawn must preserve both.
  const barePath = join(root, 'old.git')
  const workPath = join(root, 'old-work')
  await mkdir(barePath, { recursive: true })
  await mkdir(workPath, { recursive: true })
  git(barePath, ['init', '--bare', '-q'])
  git(workPath, ['init', '-q'])
  await writeFile(join(workPath, 'f'), 'x')
  git(workPath, ['add', 'f'])
  const old = new Date(Date.now() - 800 * 86400000).toISOString()
  git(workPath, ['commit', '-q', '-m', 'old'], { GIT_AUTHOR_DATE: old, GIT_COMMITTER_DATE: old })
  const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
  git(workPath, ['remote', 'add', 'origin', barePath])
  git(workPath, ['push', '-q', 'origin', branch])

  const mod = await freshModule('collapse')
  const out = await mod.enrichRepos(statusFor(['old']), reposMapFor(root, ['old']))

  t.is(out[0].commits, 1, 'all-time count includes the >365d commit')
  t.ok(out[0].lastCommit && Number.isFinite(out[0].lastCommit.timestamp), 'lastCommit found despite being >365d old')
  t.is(out[0].activity.reduce((a, b) => a + b, 0), 0, 'zero activity in the last 365 days')

  // commit-graph cache was written (single file or chain form).
  const graph = existsSync(join(barePath, 'objects', 'info', 'commit-graph')) ||
    existsSync(join(barePath, 'objects', 'info', 'commit-graphs'))
  t.ok(graph, 'commit-graph written to the bare repo')
})