OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / ref-reconciler-invalidates-activity.test.js

122 lines · javascript ref:
// Refs arriving by REPLICATION must invalidate the repo-activity cache.
//
// invalidateRepoActivity was called from exactly one place: ref-update-observed,
// i.e. the PUSH path. A node that receives refs through the autobase view never
// fires it — and materializing a ref does not restamp objects/pack either, so
// the activity cache's mtime key still matches and enrichRepos keeps serving
// the old commit count until the (generous) backstop TTL expires.
//
// Measured on the live public backend before this fix:
//   real commits on main   20
//   cached commits         19
//   cached mtime           == live objects/pack mtime  (so the key MATCHED)
//   age                    5.5h and not self-correcting
// 7rnx.net/git fronts that node, so the public repo page showed 19.
//
// RefReconciler now reports "this pass changed a ref" via onChange, and the
// manager wires that to invalidateRepoActivity(repoName).

import test from 'brittle'
import { mkdtempSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'

import { RefReconciler } from '../lib/services/ref-reconciler.js'
import { execFileSync } from '../lib/_compat-exec.js'

function git (args, opts = {}) {
  return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
function tmp () { return mkdtempSync(join(tmpdir(), 'pgv3-refinv-')) }

// Minimal tracker: the autobase ref-view the reconciler projects onto disk.
function fakeTracker (refs) {
  const listeners = []
  return {
    base: { on (_e, fn) { listeners.push(fn) }, off () {} },
    async listRefs () { return refs.slice() },
    _fire () { for (const fn of listeners) fn() }
  }
}

async function makeRepo () {
  const dir = tmp()
  const bare = join(dir, 'repo.git')
  const work = join(dir, 'work')
  git(['init', '--bare', '-q', bare])
  git(['init', '-q', work])
  git(['-C', work, 'config', 'user.email', 't@t'])
  git(['-C', work, 'config', 'user.name', 'T'])
  git(['-C', work, 'config', 'commit.gpgsign', 'false'])
  writeFileSync(join(work, 'a.txt'), 'one\n')
  git(['-C', work, 'add', '.'])
  git(['-C', work, 'commit', '-q', '-m', 'c1'])
  const o1 = git(['-C', work, 'rev-parse', 'HEAD'])
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])

  writeFileSync(join(work, 'a.txt'), 'two\n')
  git(['-C', work, 'commit', '-q', '-am', 'c2'])
  const o2 = git(['-C', work, 'rev-parse', 'HEAD'])
  // Push the SECOND commit's objects into the bare repo but leave the ref at
  // o1 — this is the replication shape: objects present, ref behind.
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/incoming'])
  git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/incoming'])

  return { bare, o1, o2 }
}

test('a ref materialized by replication fires onChange', async (t) => {
  const { bare, o1, o2 } = await makeRepo()

  const fired = []
  const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
  const rec = new RefReconciler({
    tracker,
    repoPath: bare,
    onChange: () => { fired.push('change') }
  })

  // First pass: the bare repo is already at o1, so nothing changes.
  await rec.reconcile()
  t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1, 'ref starts at o1')
  t.is(fired.length, 0, 'no onChange when the pass is a no-op — cache is not needlessly dropped')

  // The view advances (a peer pushed elsewhere and it replicated to us).
  tracker.listRefs = async () => [{ ref: 'refs/heads/main', hash: o2 }]
  await rec.reconcile()

  t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o2, 'ref advanced to o2 on disk')
  t.is(fired.length, 1, 'onChange fired exactly once for the change')
})

test('deleting a materialized ref also fires onChange', async (t) => {
  const { bare, o1 } = await makeRepo()

  const fired = []
  const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
  const rec = new RefReconciler({ tracker, repoPath: bare, onChange: () => fired.push('change') })
  await rec.reconcile()
  fired.length = 0

  // View drops the ref → reconciler deletes it from the bare repo.
  tracker.listRefs = async () => []
  await rec.reconcile()
  t.is(fired.length, 1, 'a delete is a content change too')
})

test('a failing onChange never breaks reconciliation', async (t) => {
  // Cache invalidation is a nicety; projecting refs onto the bare repo is not.
  const { bare, o1, o2 } = await makeRepo()
  const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
  const rec = new RefReconciler({
    tracker,
    repoPath: bare,
    onChange: () => { throw new Error('cache exploded') }
  })
  await rec.reconcile()
  tracker.listRefs = async () => [{ ref: 'refs/heads/main', hash: o2 }]
  await rec.reconcile()
  t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o2,
    'the ref still advanced despite the hook throwing')
})