OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync / test / basic.test.js

310 lines · javascript ref:
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 });
});