const test = require('brittle');
const path = require('path');
const fs = require('fs');
const os = require('os');
const HyperSync = require('../');
const { scanRepos, repoInfo, readRefs } = require('../lib/repos');
const { deriveTopic } = require('../lib/topics');
const REPOS_DIR = path.join(os.homedir(), 'git-repos');
const tmpdir = os.tmpdir();
// ── Repo Scanner ────────────────────────────────────────────
test('scanRepos finds bare repos', async (t) => {
const repos = scanRepos(REPOS_DIR);
t.ok(repos.size > 0, 'found ' + repos.size + ' repos');
t.ok(repos.has('pear-git'), 'found pear-git');
});
test('repoInfo returns branches and size', async (t) => {
const repoPath = path.join(REPOS_DIR, 'pear-git.git');
const info = repoInfo(repoPath, 'pear-git');
t.ok(info, 'info returned');
t.is(info.name, 'pear-git');
t.ok(info.branchCount > 0, info.branchCount + ' branches');
t.ok(info.packSize > 0, 'pack size: ' + info.packSize);
t.ok(info.head, 'has head: ' + (info.head ? info.head.slice(0, 12) : 'none'));
});
test('readRefs returns branch shas', async (t) => {
const refs = readRefs(path.join(REPOS_DIR, 'pear-git.git'));
t.ok(refs.master || refs.main, 'has master or main');
const sha = refs.master || refs.main;
t.is(sha.length, 40, 'sha is 40 chars');
t.ok(/^[0-9a-f]{40}$/.test(sha), 'valid hex sha');
});
test('scanRepos handles missing directory', async (t) => {
const repos = scanRepos('/nonexistent/path');
t.is(repos.size, 0, 'empty map for missing dir');
});
test('repoInfo repoints dangling HEAD to first available branch (U40)', async (t) => {
// Reproduce the U40 scenario: bare repo created with `git init --bare`
// (HEAD → refs/heads/master), then first push is `main` so master
// never gets created. After the fix, scanRepos's repoInfo should
// detect the dangling pointer and rewrite HEAD → refs/heads/main.
const { execSync } = require('child_process');
const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-u40-'));
const bareDir = path.join(reposRoot, 'u40-test.git');
execSync(`git init --bare ${bareDir}`, { stdio: 'ignore' });
const headBefore = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
t.is(headBefore, 'ref: refs/heads/master', 'fresh bare repo HEAD points at master');
const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-u40-work-'));
const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
g('init --initial-branch=main');
g('config user.email t@t');
g('config user.name T');
fs.writeFileSync(path.join(work, 'README.md'), '# u40\n');
g('add -A');
g('commit -m seed');
g(`push ${bareDir} main`);
const headMid = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
t.is(headMid, 'ref: refs/heads/master', 'HEAD still dangling after push');
// Now scanRepos runs repoInfo, which should call repointHeadIfDangling.
const repos = scanRepos(reposRoot);
t.ok(repos.has('u40-test'), 'scanned the repo');
const headAfter = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
t.is(headAfter, 'ref: refs/heads/main', 'HEAD repointed to main after scan');
const readme = execSync(`git -C ${bareDir} show HEAD:README.md`, { encoding: 'utf-8' });
t.is(readme, '# u40\n', 'git show HEAD:README.md works after repoint');
});
test('repoInfo.lastUpdated tracks loose refs, not just HEAD/packed-refs', async (t) => {
// Reproduces the live-forge shape: on the public backend 92 of 147 repos
// have NO packed-refs at all — every ref is a loose file under
// refs/heads/. lastUpdated was computed as max(HEAD mtime, packed-refs
// mtime), so for those repos it reported HEAD's mtime, which a push does
// not touch. The field then reads as ~repo-creation time forever: on the
// live fleet 69 of 99 checkable repos were >1h stale against their own
// newest ref, which makes lastUpdated useless as a recency signal.
//
// --initial-branch=main deliberately matches the pushed branch: with a
// dangling HEAD, repointHeadIfDangling() REWRITES HEAD during repoInfo(),
// freshening its mtime and hiding the very bug under test.
const { execSync } = require('child_process');
const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-lastupd-'));
const bareDir = path.join(reposRoot, 'lastupd-test.git');
execSync(`git init --bare --initial-branch=main ${bareDir}`, { stdio: 'ignore' });
const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-lastupd-work-'));
const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
g('init --initial-branch=main');
g('config user.email t@t');
g('config user.name T');
fs.writeFileSync(path.join(work, 'README.md'), '# lastupd\n');
g('add -A');
g('commit -m seed');
g(`push ${bareDir} main`);
// Prod shape: loose ref, no packed-refs.
const loosePath = path.join(bareDir, 'refs', 'heads', 'main');
t.ok(fs.existsSync(loosePath), 'ref is loose at refs/heads/main');
t.absent(fs.existsSync(path.join(bareDir, 'packed-refs')), 'no packed-refs (matches 92/147 live repos)');
// Pin both mtimes explicitly so the assertion cannot race the clock:
// HEAD is old (untouched since creation), the ref is recent (the push).
const OLD = new Date(Date.now() - 30 * 86400000);
const NEW = new Date();
fs.utimesSync(path.join(bareDir, 'HEAD'), OLD, OLD);
fs.utimesSync(loosePath, NEW, NEW);
const info = repoInfo(bareDir, 'lastupd-test');
t.ok(info, 'info returned');
// The push is the repo's last activity, so lastUpdated must reflect the
// ref write — not HEAD, which predates it by 30 days.
const skewMs = Math.abs(info.lastUpdated - NEW.getTime());
t.ok(skewMs < 2000, 'lastUpdated tracks the loose ref (skew ' + Math.round(skewMs) + 'ms)');
t.ok(info.lastUpdated > OLD.getTime() + 86400000, 'lastUpdated is not HEAD mtime');
});
test('scanRepos reuses packSize for an unchanged repo', async (t) => {
// dirSize() recursively stats every file in every repo and is ~89% of a
// scan (364ms of 409ms across 147 live repos, walking 7.9GB). scanRepos is
// synchronous, so that time is a hard event-loop stall for the whole
// daemon -- and the repos-dir watcher re-runs it on every push.
//
// Nothing about an untouched repo's footprint can change between scans, so
// a rescan should carry the previous size forward instead of re-walking.
//
// Asserted behaviourally, not by timing: a file is added somewhere the
// change-key deliberately ignores, so REUSING the cache is observable as
// the size staying put, and invalidating it is observable as the size
// moving. packSize is display-only (a byte count on a card), so that
// window of staleness costs nothing.
const { execSync } = require('child_process');
const root = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-packsize-'));
const bareDir = path.join(root, 'sz.git');
execSync(`git init --bare --initial-branch=main ${bareDir}`, { stdio: 'ignore' });
const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-packsize-work-'));
const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
g('init --initial-branch=main');
g('config user.email t@t');
g('config user.name T');
fs.writeFileSync(path.join(work, 'README.md'), '# sz\n');
g('add -A');
g('commit -m seed');
g(`push ${bareDir} main`);
const m1 = scanRepos(root);
const size1 = m1.get('sz').packSize;
t.ok(size1 > 0, 'baseline size measured: ' + size1);
// info/ is inside the repo (so dirSize counts it) but is not refs/ or
// objects/, so it does not move the change-key.
fs.writeFileSync(path.join(bareDir, 'info', 'filler'), 'x'.repeat(20000));
const m2 = scanRepos(root, { previous: m1 });
t.is(m2.get('sz').packSize, size1, 'unchanged repo: size carried forward, tree not re-walked');
// Control: prove the filler really is counted, so the assertion above is
// meaningful rather than trivially true.
const m3 = scanRepos(root);
t.ok(m3.get('sz').packSize > size1, 'a fresh scan does see the added bytes');
// A ref write must invalidate: this is a real push's signature.
const future = new Date(Date.now() + 10000);
fs.utimesSync(path.join(bareDir, 'refs', 'heads', 'main'), future, future);
const m4 = scanRepos(root, { previous: m2 });
t.ok(m4.get('sz').packSize > size1, 'changed repo: re-walked and size updated');
});
// ── Topic Derivation ────────────────────────────────────────
test('deriveTopic is deterministic', async (t) => {
const key = Buffer.alloc(32, 0x42);
const t1 = deriveTopic(key, 'my-repo');
const t2 = deriveTopic(key, 'my-repo');
const t3 = deriveTopic(key, 'other-repo');
t.alike(t1, t2, 'same key+name = same topic');
t.unlike(t1, t3, 'different name = different topic');
});
test('deriveTopic accepts hex string', async (t) => {
const key = Buffer.alloc(32, 0x42);
const hex = key.toString('hex');
const t1 = deriveTopic(key, 'repo');
const t2 = deriveTopic(hex, 'repo');
t.alike(t1, t2, 'buffer and hex produce same topic');
});
// ── HyperSync Lifecycle ─────────────────────────────────────
test('HyperSync scan and status', async (t) => {
const sync = new HyperSync({ repos: REPOS_DIR });
await sync.ready();
t.ok(sync.repos.size > 0, 'scanned ' + sync.repos.size + ' repos');
t.ok(sync.serverKey, 'has server key');
const status = sync.status();
t.is(status.repoCount, sync.repos.size);
t.is(status.serving, 0, 'not serving yet');
t.is(status.remotes, 0, 'no remotes');
t.ok(status.serverKey, 'status has server key');
await sync.close();
});
test('HyperSync info for specific repo', async (t) => {
const sync = new HyperSync({ repos: REPOS_DIR });
await sync.ready();
const info = sync.info('pear-git');
t.ok(info, 'got info');
t.is(info.name, 'pear-git');
t.ok(info.refs, 'has refs');
t.is(info.serving, false, 'not serving');
const missing = sync.info('nonexistent');
t.is(missing, null, 'null for missing repo');
await sync.close();
});
test('HyperSync remotes', async (t) => {
const sync = new HyperSync({ repos: REPOS_DIR });
await sync.ready();
sync.addRemote('stortron', Buffer.alloc(32, 0x01).toString('hex'));
t.is(sync.remotes.size, 1);
t.ok(sync.remotes.has('stortron'));
sync.removeRemote('stortron');
t.is(sync.remotes.size, 0);
await sync.close();
});
test('HyperSync rescan detects changes', async (t) => {
// Create a temp repos directory with one repo
const tmpRepos = path.join(tmpdir, 'hyper-sync-test-' + Date.now());
fs.mkdirSync(tmpRepos, { recursive: true });
const sync = new HyperSync({ repos: tmpRepos });
await sync.ready();
t.is(sync.repos.size, 0, 'empty initially');
// Create a bare repo
const { execSync } = require('child_process');
execSync('git init --bare ' + path.join(tmpRepos, 'test.git'), { stdio: 'pipe' });
await sync.scan();
t.is(sync.repos.size, 1, 'found new repo after rescan');
t.ok(sync.repos.has('test'), 'found test repo');
await sync.close();
fs.rmSync(tmpRepos, { recursive: true });
});
test('readFileRaw returns exact binary bytes (readFile would corrupt)', async (t) => {
// Commit a blob with non-UTF-8 bytes (0x00, 0x80, 0xFF) — a stand-in for an
// image. readFile() decodes to a string and mangles these; readFileRaw()
// must round-trip them byte-for-byte.
const { execSync } = require('child_process');
const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-raw-'));
const bareDir = path.join(reposRoot, 'raw-test.git');
execSync(`git init --bare ${bareDir}`, { stdio: 'ignore' });
const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-raw-work-'));
const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
g('init --initial-branch=main');
g('config user.email t@t');
g('config user.name T');
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x80, 0xff, 0x0a, 0x1f, 0x2b]);
fs.writeFileSync(path.join(work, 'pixel.png'), bytes);
g('add -A');
g('commit -m img');
g(`push ${bareDir} main`);
const sync = new HyperSync({ repos: reposRoot });
await sync.ready();
const raw = await sync.readFileRaw('raw-test', 'pixel.png', 'main');
t.ok(Buffer.isBuffer(raw), 'readFileRaw returns a Buffer');
t.alike(raw, bytes, 'bytes round-trip exactly');
// Demonstrate why raw is needed: the string path loses the original bytes.
const asStr = await sync.readFile('raw-test', 'pixel.png', 'main');
t.not(Buffer.from(asStr).length, bytes.length, 'readFile string path corrupts byte length');
await sync.close();
fs.rmSync(reposRoot, { recursive: true });
fs.rmSync(work, { recursive: true });
});