import test from 'brittle'
import { mkdtempSync, existsSync, readFileSync, mkdirSync, rmSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { syncHandlers } from '../lib/services/sync.js'
// `create-repo` collapses the four-command hosting dance (init --bare /
// symbolic-ref HEAD / scan / push / scan) into one daemon call. The failure
// modes it removes are both silent: a skipped symbolic-ref leaves clones with
// no checked-out branch, and a skipped scan leaves the repo unserved.
function fakeSvc (reposDir) {
const calls = { scan: 0, serve: 0 }
return {
_reposDir: reposDir,
_sync: {
calls,
async scan () { calls.scan++; return new Map() },
async serve () { calls.serve++ }
}
}
}
function handler (svc) {
const h = syncHandlers(svc).find((x) => x.name === 'create-repo')
if (!h) throw new Error('create-repo handler not registered')
return h.fn
}
const UDS = { transport: 'uds' }
function freshDir (tag) {
const root = mkdtempSync(join(tmpdir(), 'pgv3-mkrepo-' + tag + '-'))
const repos = join(root, 'git-repos')
mkdirSync(repos, { recursive: true })
return { root, repos }
}
test('create-repo inits a bare repo, sets HEAD, and serves it', async (t) => {
const { root, repos } = freshDir('ok')
const svc = fakeSvc(repos)
const res = await handler(svc)(UDS, { name: 'myapp', branch: 'main' })
const dir = join(repos, 'myapp.git')
t.is(res.name, 'myapp', 'returns the name')
t.is(res.branch, 'main', 'returns the branch')
t.is(res.path, dir, 'returns the bare repo path')
t.ok(res.serving, 'reports serving')
t.ok(existsSync(dir), 'bare repo created')
t.ok(existsSync(join(dir, 'HEAD')), 'HEAD exists')
t.is(readFileSync(join(dir, 'HEAD'), 'utf8').trim(), 'ref: refs/heads/main', 'HEAD points at the branch')
// No `.git/index` / worktree — it must be BARE, or pushes to it fail.
t.absent(existsSync(join(dir, '.git')), 'is a bare repo, not a worktree')
t.is(svc._sync.calls.scan, 1, 'scanned (registers + installs post-receive hook)')
t.is(svc._sync.calls.serve, 1, 'served (announced on the swarm)')
rmSync(root, { recursive: true, force: true })
})
test('create-repo defaults the branch to main', async (t) => {
const { root, repos } = freshDir('dflt')
const res = await handler(fakeSvc(repos))(UDS, { name: 'nobranch' })
t.is(res.branch, 'main', 'defaulted to main')
t.is(readFileSync(join(repos, 'nobranch.git', 'HEAD'), 'utf8').trim(), 'ref: refs/heads/main', 'HEAD set anyway')
rmSync(root, { recursive: true, force: true })
})
test('create-repo honours a non-default branch', async (t) => {
const { root, repos } = freshDir('br')
const res = await handler(fakeSvc(repos))(UDS, { name: 'legacy', branch: 'master' })
t.is(res.branch, 'master', 'branch echoed')
t.is(readFileSync(join(repos, 'legacy.git', 'HEAD'), 'utf8').trim(), 'ref: refs/heads/master', 'HEAD points at master')
rmSync(root, { recursive: true, force: true })
})
test('create-repo refuses to clobber an existing repo', async (t) => {
const { root, repos } = freshDir('dup')
const svc = fakeSvc(repos)
await handler(svc)(UDS, { name: 'twice' })
await t.exception(() => handler(svc)(UDS, { name: 'twice' }), /already exists/, 'second create rejected')
rmSync(root, { recursive: true, force: true })
})
// The name lands on the filesystem under reposDir AND is announced as a swarm
// repo id, so it is validated rather than trusted.
test('create-repo rejects path traversal and separators in the name', async (t) => {
const { root, repos } = freshDir('bad')
const fn = handler(fakeSvc(repos))
for (const bad of ['../escape', 'a/b', '/abs', '.hidden', '', 'x/../../y']) {
await t.exception(() => fn(UDS, { name: bad }), /invalid repo name/, `rejected ${JSON.stringify(bad)}`)
}
rmSync(root, { recursive: true, force: true })
})
test('create-repo rejects a malformed branch name', async (t) => {
const { root, repos } = freshDir('badbr')
const fn = handler(fakeSvc(repos))
await t.exception(() => fn(UDS, { name: 'ok1', branch: '--upload-pack=evil' }), /invalid branch name/, 'rejected flag-like branch')
await t.exception(() => fn(UDS, { name: 'ok2', branch: '../../etc/passwd' }), /invalid branch name/, 'rejected traversal in branch')
// An empty branch is not malformed — it falls back to the default.
const res = await fn(UDS, { name: 'ok3', branch: '' })
t.is(res.branch, 'main', 'empty branch falls back to main rather than erroring')
rmSync(root, { recursive: true, force: true })
})
test('create-repo requires auth on non-uds transports', async (t) => {
const { root, repos } = freshDir('auth')
const fn = handler(fakeSvc(repos))
await t.exception(() => fn({ transport: 'http' }, { name: 'nope' }), /authentication required/, 'anonymous http rejected')
// an identified http caller is allowed
const res = await fn({ transport: 'http', identity: { publicKey: 'ab' } }, { name: 'yes' })
t.is(res.name, 'yes', 'identified caller allowed')
rmSync(root, { recursive: true, force: true })
})
test('create-repo fails clearly when the daemon has no sync service', async (t) => {
const svc = { _reposDir: '/tmp', _sync: null }
await t.exception(() => handler(svc)(UDS, { name: 'x' }), /sync service not active/, 'clear error, not a crash')
})