// Ordering /repos on a REPLICATED signal instead of a local file mtime.
//
// THE BUG. sortReposByRecency ordered on `lastUpdated`, which hyper-sync
// derives from the mtime of HEAD / packed-refs / refs/** on the local disk
// (hyper-sync/lib/repos.js). That is a per-machine number, so the two
// load-balanced backends of 7rnx.net/git could not agree by construction.
// Measured 2026-09-05: the same 135 repos on both, and all 135 with a
// different lastUpdated — 67 differing by more than an hour. Whichever
// backend the LB picked decided the front page's order.
//
// Three independent ways it drifts:
// 1. clone time. A repo nobody has pushed to since import carries the time
// THAT host first fetched it (2026-06-03 on tabertron, 2026-04-21 on
// stortron for one bulk-import cohort).
// 2. staleness. See mirror-pull-refresh.test.js.
// 3. self-inflicted. The 5-minute reconcileAll sweep force-writes refs from
// the tracker view onto disk, bumping the mtime of a repo nothing
// happened to. ifsrenderer-linux went from rank 20 to rank 1 on stortron
// that way, with refs/heads/linux-port stamped at the exact second of the
// sweep.
//
// THE FIX. The per-repo tracker's ref-view already carries a `timestamp` per
// ref, stamped by the writer that ACCEPTED the push and replicated verbatim
// to every peer. RefReconciler reads that view on every pass anyway, so
// recording max(timestamp) costs no extra autobase work, and every backend
// that replicates the tracker derives the identical number.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import {
recordRefActivity, getRefActivity, mergeRefActivity,
configureRefActivity, flushRefActivity, resetRefActivityForTests
} from '../lib/services/repo-ref-activity.js'
import { sortReposByRecency } from '../lib/web/pages/repos.js'
function tmpdir (t) {
const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refact-'))
t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
return dir
}
test('records the newest ref timestamp and never moves backwards', async (t) => {
resetRefActivityForTests()
t.teardown(resetRefActivityForTests)
t.is(getRefActivity('demo'), null, 'unknown repo has no value')
recordRefActivity('demo', 1000)
t.is(getRefActivity('demo'), 1000, 'first value recorded')
recordRefActivity('demo', 5000)
t.is(getRefActivity('demo'), 5000, 'advances')
// A pass can see an OLDER max than a previous pass: a ref deletion drops the
// newest entry out of the view. That is not a reason to un-know the repo's
// activity, and letting it fall back would re-shuffle the page.
recordRefActivity('demo', 2000)
t.is(getRefActivity('demo'), 5000, 'never regresses')
recordRefActivity('demo', 0)
recordRefActivity('demo', null)
recordRefActivity('', 9999)
t.is(getRefActivity('demo'), 5000, 'zero/null/nameless are ignored')
})
test('mergeRefActivity annotates repo records without inventing fields', async (t) => {
resetRefActivityForTests()
t.teardown(resetRefActivityForTests)
recordRefActivity('alpha', 4242)
const out = mergeRefActivity([{ name: 'alpha' }, { name: 'beta' }])
t.is(out[0].lastRefUpdate, 4242, 'known repo annotated')
t.absent('lastRefUpdate' in out[1], 'unknown repo left alone')
t.alike(mergeRefActivity(null), [], 'non-array input is safe')
})
test('the replicated timestamp outranks the local mtime', async (t) => {
resetRefActivityForTests()
t.teardown(resetRefActivityForTests)
// `quiet` looks fresher on disk — this is the reconcile-sweep bump, or a
// late clone. `busy` is the one that actually got a push.
const repos = [
{ name: 'quiet', branches: 1, lastUpdated: 9_000_000, lastRefUpdate: 1_000_000 },
{ name: 'busy', branches: 1, lastUpdated: 1_000, lastRefUpdate: 8_000_000 }
]
t.alike(sortReposByRecency(repos).map((r) => r.name), ['busy', 'quiet'],
'ordered by the replicated ref timestamp, not the mtime')
// Two backends whose mtimes disagree wildly still agree on the order.
const backendA = [
{ name: 'a', branches: 1, lastUpdated: 1, lastRefUpdate: 300 },
{ name: 'b', branches: 1, lastUpdated: 999_999, lastRefUpdate: 200 },
{ name: 'c', branches: 1, lastUpdated: 500, lastRefUpdate: 100 }
]
const backendB = [
{ name: 'a', branches: 1, lastUpdated: 777_777, lastRefUpdate: 300 },
{ name: 'b', branches: 1, lastUpdated: 3, lastRefUpdate: 200 },
{ name: 'c', branches: 1, lastUpdated: 42, lastRefUpdate: 100 }
]
t.alike(
sortReposByRecency(backendA).map((r) => r.name),
sortReposByRecency(backendB).map((r) => r.name),
'both backends produce the same page order'
)
})
test('falls back to the mtime when a repo has no replicated timestamp', async (t) => {
// A repo whose tracker has never been mounted here (no reconciler, so no
// value yet) must not sink below every repo that has one — it is ordered on
// whatever local signal exists, as before.
const repos = [
{ name: 'tracked', branches: 1, lastRefUpdate: 5_000 },
{ name: 'untracked-new', branches: 1, lastUpdated: 9_000 },
{ name: 'untracked-old', branches: 1, lastUpdated: 1_000 }
]
t.alike(sortReposByRecency(repos).map((r) => r.name),
['untracked-new', 'tracked', 'untracked-old'],
'mixed records order on one comparable scale')
// Empty repos still sink regardless of which signal they carry.
const withEmpty = [
{ name: 'empty', branches: 0, lastRefUpdate: 9_999_999 },
{ name: 'real', branches: 1, lastRefUpdate: 1 }
]
t.alike(sortReposByRecency(withEmpty).map((r) => r.name), ['real', 'empty'],
'repos with no history still sink to the bottom')
})
test('survives a restart — the value is persisted and reloaded', async (t) => {
const dir = tmpdir(t)
resetRefActivityForTests()
t.teardown(resetRefActivityForTests)
await configureRefActivity(dir)
recordRefActivity('alpha', 12345)
recordRefActivity('beta', 67890)
await flushRefActivity()
// Model a daemon restart: drop all process state, re-configure the same dir.
resetRefActivityForTests()
await configureRefActivity(dir)
t.is(getRefActivity('alpha'), 12345, 'alpha survived the restart')
t.is(getRefActivity('beta'), 67890, 'beta survived the restart')
// Without this the first render after every restart falls back to mtimes,
// which is exactly the disagreement being fixed — the boot ensureFor walk
// repopulates it, but not before the first visitor arrives.
t.ok(fs.existsSync(join(dir, 'repo-ref-activity.json')), 'persisted on disk')
})
test('a corrupt or absent cache file is not fatal', async (t) => {
const dir = tmpdir(t)
resetRefActivityForTests()
t.teardown(resetRefActivityForTests)
fs.writeFileSync(join(dir, 'repo-ref-activity.json'), '{not json')
await configureRefActivity(dir)
t.is(getRefActivity('anything'), null, 'starts empty rather than throwing')
recordRefActivity('alpha', 1)
t.is(getRefActivity('alpha'), 1, 'still usable after a bad load')
})
test('an EXACT tie orders identically on both backends', async (t) => {
// Found in production the hour 0.1.250 shipped. lastRefUpdate agreed to the
// millisecond across tabertron and stortron (1788551868967 for both
// proc-nodes and blender-bridge — one batch publish stamps one timestamp),
// and with no tiebreak the comparator returned 0, so Array.sort left them in
// INPUT order. That input is sync.repos, a Map in readdir order, which is
// per-host: 134 of 135 positions matched and those two were swapped.
//
// The replicated key made ties COMMON — mtimes were near-unique by accident,
// a batch publish is not. Names are unique per daemon, so tie-breaking on
// name makes the order total and host-independent.
const tied = (order) => order.map((name) => ({ name, branches: 1, lastRefUpdate: 1788551868967 }))
t.alike(
sortReposByRecency(tied(['proc-nodes', 'blender-bridge'])).map((r) => r.name),
sortReposByRecency(tied(['blender-bridge', 'proc-nodes'])).map((r) => r.name),
'the same tied set orders the same way whatever order it arrives in'
)
t.alike(sortReposByRecency(tied(['proc-nodes', 'blender-bridge'])).map((r) => r.name),
['blender-bridge', 'proc-nodes'], 'tie broken by name')
// The tie is only reached when the recency is equal — it must never outrank
// the timestamp itself.
t.alike(
sortReposByRecency([
{ name: 'aaa', branches: 1, lastRefUpdate: 1 },
{ name: 'zzz', branches: 1, lastRefUpdate: 2 }
]).map((r) => r.name),
['zzz', 'aaa'],
'a newer repo still wins over an alphabetically earlier one'
)
// Repos with NO signal at all (both keys absent) tie at 0 — same rule.
t.alike(
sortReposByRecency([{ name: 'b', branches: 1 }, { name: 'a', branches: 1 }]).map((r) => r.name),
['a', 'b'],
'repos with no signal at all are also deterministic'
)
})