OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-set-head.test.js

121 lines · javascript ref:
// `repo-set-head` — choose a repo's default branch deliberately.
//
// HEAD is replicated now (meta:head), but a repo whose nodes ALREADY disagree
// gets seeded by whichever writable node reconciles first, which is a race. On
// the live pair 4 of 142 repos disagreed, e.g. hyper-svc was `main` (20
// commits) on one backend and `bare-migration` (7) on the other, and the
// public saw whichever the load balancer hit. This makes it a decision.
//
// Admin-gated: the default branch is what the repo page renders and what
// `git clone` checks out.

import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { Client } from 'hyper-svc'

import { startPearGit } from '../index.js'
import { execFileSync } from '../lib/_compat-exec.js'

process.env.PGV3_USE_RAW_IDENTITY = '1'

function git (args, opts = {}) {
  return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
const tmp = (p) => mkdtempSync(join(tmpdir(), p))

// Daemon with one served repo carrying two real branches.
async function setup (t) {
  const storage = tmp('pgv3-sethead-')
  const node = await startPearGit({
    storage, uds: join(tmp('pgv3-sethead-s-'), 'svc.sock'),
    http: { port: 0 }, swarm: false, autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  const bare = join(node.svc._reposDir, 'demo.git')
  mkdirSync(node.svc._reposDir, { recursive: true })
  git(['init', '--bare', '-q', bare])

  const work = join(storage, 'work')
  git(['init', '-q', '-b', 'main', 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'])
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])
  git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/legacy'])
  git(['--git-dir', bare, 'symbolic-ref', 'HEAD', 'refs/heads/legacy'])

  const client = await Client.discover({ uds: node.svc.uds.path })
  t.teardown(async () => { try { await client.close() } catch {} })
  return { node, client, bare }
}

const headOf = (bare) => git(['--git-dir', bare, 'symbolic-ref', 'HEAD'])

test('sets the default branch locally AND replicates it', async (t) => {
  const { node, client, bare } = await setup(t)
  t.is(headOf(bare), 'refs/heads/legacy', 'starts on the wrong branch')

  const res = await client.call('repo-set-head', { name: 'demo', ref: 'main' })
  t.is(res.head, 'refs/heads/main', 'reports the new head')
  t.ok(res.replicated, 'reports it was replicated')

  t.is(headOf(bare), 'refs/heads/main', 'applied on disk immediately, not on the next sweep')
  const tracker = await node.svc.issueRegistry.getForRepo('demo')
  t.is(await tracker.getMeta('head'), 'refs/heads/main', 'and recorded in the replicated view')
})

test('accepts a full refname as well as a bare branch name', async (t) => {
  const { client, bare } = await setup(t)
  await client.call('repo-set-head', { name: 'demo', ref: 'refs/heads/main' })
  t.is(headOf(bare), 'refs/heads/main', 'refs/heads/<x> form works')
})

test('refuses a branch that does not exist', async (t) => {
  // Writing a HEAD pointing at a missing ref would give EVERY node a dangling
  // default and make `git clone` find no branch at all — worse than the
  // divergence this command exists to fix.
  const { client, bare } = await setup(t)
  await t.exception(
    client.call('repo-set-head', { name: 'demo', ref: 'no-such-branch' }),
    /no such branch/,
    'refused, and the error lists the branches that do exist'
  )
  t.is(headOf(bare), 'refs/heads/legacy', 'HEAD untouched by the failed attempt')
})

test('refuses an unknown repo and a malformed name', async (t) => {
  const { client } = await setup(t)
  await t.exception(client.call('repo-set-head', { name: 'nope', ref: 'main' }), /unknown repo/)
  await t.exception(client.call('repo-set-head', { name: '../../etc', ref: 'main' }), /invalid repo name/)
})

test('requires both arguments', async (t) => {
  const { client } = await setup(t)
  await t.exception(client.call('repo-set-head', { name: 'demo' }), /requires \{ ref \}/)
  await t.exception(client.call('repo-set-head', { ref: 'main' }), /requires \{ name \}/)
})

test('is not reachable anonymously over HTTP', async (t) => {
  // It is admin-gated AND not in ANONYMOUS_METHODS, so the org #12 gate
  // refuses it before the handler runs.
  const { node } = await setup(t)
  const port = node.svc.http.server.address().port
  const http = await import('#node:http')
  const body = JSON.stringify({ name: 'demo', ref: 'main' })
  const status = await new Promise((resolve, reject) => {
    const req = http.default.request({
      method: 'POST', hostname: '127.0.0.1', port, path: '/rpc/repo-set-head',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
    }, (res) => { res.on('data', () => {}); res.on('end', () => resolve(res.statusCode)) })
    req.on('error', reject); req.write(body); req.end()
  })
  t.is(status, 403, 'anonymous HTTP refused')
})