OverviewFilesCommitsIssuesDocsReleasesPeers

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

203 lines · javascript ref:
// gip-overlay P2 — bare-repo reconciliation.
//
// The autobase ref-view is authority; the on-disk bare repo follows it.
// RefReconciler watches a per-repo HyperIssues tracker and, on every view
// update, forces the bare repo's refs to match the view via `git update-ref`
// — fetching missing objects (overlay in prod, injected here) before moving
// a ref, and never pointing a ref at an absent object. Forcing to the view
// value is also how a locally-accepted-but-globally-rejected push is rolled
// back. Real git + a real tracker, no mocks.

import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { HyperIssues } from 'hyper-issues'

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

function git (args, opts = {}) {
  return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}

// Build a bare repo that CONTAINS the objects for two commits (oid1 -> oid2)
// but has NO refs/heads/main — so the reconciler is what materializes the ref
// from the view. Objects survive the ref deletion (no gc runs).
function seedRepo () {
  const dir = mkdtempSync(join(tmpdir(), 'pgv3-ref-'))
  const bare = join(dir, 'r.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 oid1 = git(['-C', work, 'rev-parse', 'HEAD'])
  writeFileSync(join(work, 'a.txt'), 'two\n')
  git(['-C', work, 'commit', '-q', '-am', 'c2'])
  const oid2 = git(['-C', work, 'rev-parse', 'HEAD'])
  // Push both commits' objects into the bare repo, then drop the ref so only
  // the objects remain.
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])
  git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/main'])
  return { dir, bare, work, oid1, oid2 }
}

function bareRef (bare, ref) {
  try { return git(['--git-dir', bare, 'rev-parse', '--verify', '--quiet', ref]) || null }
  catch { return null }
}

async function makeTracker (t, namespace = 'issues:repo:r') {
  const store = new Corestore(mkdtempSync(join(tmpdir(), 'pgv3-ref-store-')))
  const tracker = new HyperIssues(store, { namespace })
  await tracker.ready()
  t.teardown(async () => {
    try { await tracker.close() } catch {}
    try { await store.close() } catch {}
  })
  return tracker
}

test('reconcile materializes a view ref into the bare repo', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t)
  const rec = new RefReconciler({ tracker, repoPath: repo.bare })

  t.is(bareRef(repo.bare, 'refs/heads/main'), null, 'precondition: no ref, objects present')

  await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
  await rec.reconcile()

  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'bare ref now matches the view')
})

test('reconcile fast-forwards the bare ref when the view moves', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t, 'issues:repo:r2')
  const rec = new RefReconciler({ tracker, repoPath: repo.bare })

  await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
  await rec.reconcile()
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)

  await tracker.updateRef('refs/heads/main', repo.oid2, 'user-1', repo.oid1) // FF in view
  await rec.reconcile()
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid2, 'bare ref followed the view forward')
})

test('reconcile rolls a divergent local ref back to the view value', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t, 'issues:repo:r3')
  const rec = new RefReconciler({ tracker, repoPath: repo.bare })

  await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
  await rec.reconcile()
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)

  // A locally-accepted-but-globally-rejected push moved the bare ref ahead.
  git(['--git-dir', repo.bare, 'update-ref', 'refs/heads/main', repo.oid2])
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid2, 'bare diverged from view')

  await rec.reconcile() // view still says oid1
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'reconcile rolled bare back to the view value')
})

test('reconcile deletes a bare ref it materialized once the view drops it', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t, 'issues:repo:r4')
  const rec = new RefReconciler({ tracker, repoPath: repo.bare })

  await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
  await rec.reconcile()
  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)

  await tracker.deleteRef('refs/heads/main', 'user-1')
  await rec.reconcile()
  t.is(bareRef(repo.bare, 'refs/heads/main'), null, 'bare ref deleted to match the view')
})

test('reconcile never points a ref at an absent object (skips, no corruption)', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t, 'issues:repo:r5')
  const missing = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'

  const asked = []
  const rec = new RefReconciler({
    tracker,
    repoPath: repo.bare,
    ensureObjects: async (oids) => { asked.push(...oids) } // overlay stub: fetches nothing
  })

  await tracker.updateRef('refs/heads/ghost', missing, 'user-1')
  await rec.reconcile()

  t.is(bareRef(repo.bare, 'refs/heads/ghost'), null, 'ref not created for a missing object')
  t.alike(asked, [missing], 'ensureObjects was asked for the missing oid')
})

test('reconcile materializes after ensureObjects supplies the missing object', async (t) => {
  // Seed a bare repo that holds ONLY oid1's objects (not oid2), so moving the
  // ref to oid2 genuinely requires ensureObjects to fetch it first.
  const dir = mkdtempSync(join(tmpdir(), 'pgv3-ref-'))
  const bare = join(dir, 'r.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 oid1 = git(['-C', work, 'rev-parse', 'HEAD'])
  writeFileSync(join(work, 'a.txt'), 'two\n')
  git(['-C', work, 'commit', '-q', '-am', 'c2'])
  const oid2 = git(['-C', work, 'rev-parse', 'HEAD'])
  // bring ONLY oid1 into the bare repo, then drop the ref (objects remain)
  git(['-C', work, 'push', '-q', bare, oid1 + ':refs/heads/_seed'])
  git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/_seed'])

  const tracker = await makeTracker(t, 'issues:repo:r6')
  let supplied = false
  const rec = new RefReconciler({
    tracker,
    repoPath: bare,
    ensureObjects: async (oids) => {
      // overlay stand-in: fetch the missing objects from the work clone
      git(['--git-dir', bare, 'fetch', '-q', work, ...oids])
      supplied = true
    }
  })

  await tracker.updateRef('refs/heads/main', oid2, 'user-1')
  await rec.reconcile()

  t.ok(supplied, 'ensureObjects was invoked for the missing object')
  t.is(bareRef(bare, 'refs/heads/main'), oid2, 'ref materialized after the fetch')
})

test('reconcile repoints a dangling HEAD to the materialized branch', async (t) => {
  const repo = seedRepo()
  const tracker = await makeTracker(t, 'issues:repo:r-head')
  const rec = new RefReconciler({ tracker, repoPath: repo.bare })

  // Force the dangling-HEAD case: HEAD -> refs/heads/master, but master never
  // exists (the branch is `main`). This is what `git init --bare` leaves on a
  // reconciler-materialized repo, and why `git rev-parse HEAD` / `git clone`
  // find no default branch even though the objects + `main` ref are present.
  git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD', 'refs/heads/master'])
  t.is(git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD']), 'refs/heads/master', 'precondition: HEAD dangling at master')

  await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
  await rec.reconcile()

  t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'main ref materialized')
  t.is(git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD']), 'refs/heads/main', 'dangling HEAD repointed to the existing branch (main)')
})