| … | … | @@ -104,6 +104,7 @@ import { userHandlers } from './handlers/users.js' |
| 104 | 104 | import { startDeployService, deployHandlers } from './services/deploy.js' |
| 105 | 105 | import { configureRepoActivityCache, enrichRepos as enrichReposForWarm, invalidateRepoActivity, flushRepoActivityCache } from './services/repo-activity.js' |
| 106 | 106 | import { configurePushIndex } from './services/push-index.js' |
| 107 | import { configureRefActivity, flushRefActivity } from './services/repo-ref-activity.js' | |
| 107 | 108 | import { configureIssueIndex, backfillIssueIndex } from './services/issue-index.js' |
| 108 | 109 | import { startSyncService, syncHandlers } from './services/sync.js' |
| 109 | 110 | import { RepoEventLog } from './services/repo-event-log.js' |
| … | … | @@ -168,6 +169,14 @@ export async function startPearGit (opts = {}) { |
| 168 | 169 | // Rebuildable derived data — safe to lose. See lib/services/push-index.js. |
| 169 | 170 | await configurePushIndex(opts.storage).catch(() => {}) |
| 170 | 171 | |
| 172 | // Load the persisted replicated last-ref-update index — the /repos ordering | |
| 173 | // key. The boot ensureFor walk below repopulates it from the trackers, but | |
| 174 | // not before the first visitor arrives, and without a value the page falls | |
| 175 | // back to local mtimes, which is the cross-backend disagreement this exists | |
| 176 | // to fix. Rebuildable derived data — safe to lose. See | |
| 177 | // lib/services/repo-ref-activity.js. | |
| 178 | await configureRefActivity(opts.storage).catch(() => {}) | |
| 179 | ||
| 171 | 180 | // Load the persisted issue-author index (public profile "recent issues"). |
| 172 | 181 | // Rebuildable derived data — safe to lose. See lib/services/issue-index.js. |
| 173 | 182 | await configureIssueIndex(opts.storage).catch(() => {}) |
| … | … | @@ -3146,6 +3155,10 @@ export async function startPearGit (opts = {}) { |
| 3146 | 3155 | // boot — the "slow on the first load after a restart" path. Rebuildable |
| 3147 | 3156 | // derived data, so a failure here is not worth failing shutdown. |
| 3148 | 3157 | try { await flushRepoActivityCache() } catch {} |
| 3158 | // Same reasoning for the /repos ordering key: its persist timer is | |
| 3159 | // unref'd, so a restart would otherwise order the first page on | |
| 3160 | // mtimes until the boot reconcile walk catches up. | |
| 3161 | try { await flushRefActivity() } catch {} | |
| 3149 | 3162 | |
| 3150 | 3163 | // Drain any in-flight auto-build jobs first so they don't write to |
| 3151 | 3164 | // the release dir or spawn subprocesses after the rest of the daemon |
| … | … | @@ -20,6 +20,7 @@ import fs from '#node:fs' |
| 20 | 20 | |
| 21 | 21 | import { execFileSync } from '../_compat-exec.js' |
| 22 | 22 | import { invalidateRepoActivity } from './repo-activity.js' |
| 23 | import { recordRefActivity } from './repo-ref-activity.js' | |
| 23 | 24 | import { RefReconciler } from './ref-reconciler.js' |
| 24 | 25 | import { makeReconcilingEnsureObjects } from '../git-overlay-fetch.js' |
| 25 | 26 | import { ensureExportMarker } from '../repo-access.js' |
| … | … | @@ -94,7 +95,10 @@ export class RefReconcilerManager { |
| 94 | 95 | repoPath, |
| 95 | 96 | ensureObjects, |
| 96 | 97 | logger: this._log, |
| 97 | onChange: () => { invalidateRepoActivity(repoName) } | |
| 98 | onChange: () => { invalidateRepoActivity(repoName) }, | |
| 99 | // The /repos ordering key. Recorded per pass whether or not the | |
| 100 | // pass changed anything on disk — see repo-ref-activity.js. | |
| 101 | onRefActivity: (ms) => { recordRefActivity(repoName, ms) } | |
| 98 | 102 | }) |
| 99 | 103 | await rec.start() |
| 100 | 104 | this._recs.set(repoName, rec) |
| … | … | @@ -49,6 +49,10 @@ export class RefReconciler { |
| 49 | 49 | // a callback so this service stays unaware of what depends on repo content |
| 50 | 50 | // — the manager wires it to the activity cache, which knows the repo name. |
| 51 | 51 | this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null |
| 52 | // Called with the newest `timestamp` in the view on EVERY pass, no-op or | |
| 53 | // not — it describes the view, not this disk. The /repos ordering key; see | |
| 54 | // services/repo-ref-activity.js. | |
| 55 | this._onRefActivity = typeof opts.onRefActivity === 'function' ? opts.onRefActivity : null | |
| 52 | 56 | |
| 53 | 57 | this._materialized = new Map() // refname -> oid we last wrote to the bare repo |
| 54 | 58 | this._started = false |
| … | … | @@ -191,6 +195,25 @@ export class RefReconciler { |
| 191 | 195 | const desired = new Map() |
| 192 | 196 | for (const r of refs) if (r.hash) desired.set(r.ref, r.hash) |
| 193 | 197 | |
| 198 | // Newest ref timestamp in the view — the REPLICATED last-activity signal | |
| 199 | // that /repos orders on. Reported from the array listRefs() already | |
| 200 | // returned, so it costs nothing beyond the pass we are in, and reported | |
| 201 | // unconditionally: a mirror that is fully in sync never changes a ref, and | |
| 202 | // gating this on `changed` would leave exactly those backends ordering on | |
| 203 | // local mtimes. A view with no timestamps (an older writer, or no refs at | |
| 204 | // all) reports nothing rather than a zero that would sink the repo. | |
| 205 | if (this._onRefActivity) { | |
| 206 | let newest = 0 | |
| 207 | for (const r of refs) { | |
| 208 | if (r && typeof r.timestamp === 'number' && r.timestamp > newest) newest = r.timestamp | |
| 209 | } | |
| 210 | if (newest > 0) { | |
| 211 | try { this._onRefActivity(newest) } catch (err) { | |
| 212 | this._log.warn('onRefActivity hook failed: ' + (err && err.message)) | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 194 | 217 | // Did this pass actually change the bare repo? Consumers cache things keyed |
| 195 | 218 | // on repo content (see onChange in the constructor). |
| 196 | 219 | let changed = false |
| … | … | @@ -343,6 +343,17 @@ export class RepoPuller { |
| 343 | 343 | this._cursor[this._primaryServerKey] = seq |
| 344 | 344 | this._saveCursor() |
| 345 | 345 | } |
| 346 | // The pull wrote refs and objects INSIDE an existing <repo>.git, which | |
| 347 | // the repos-dir watcher cannot see (it is a non-recursive watch on the | |
| 348 | // directory). Without this the in-memory record status() serves keeps | |
| 349 | // the boot-time branch count, pack size and lastUpdated for a repo that | |
| 350 | // has just advanced — and lastUpdated is what /repos orders on, so a | |
| 351 | // long-lived mirror ranked active repos as if they were months quiet. | |
| 352 | // Per-repo, not sync.scan(): a full walk per pulled repo would stall the | |
| 353 | // event loop through the whole drain. | |
| 354 | if (typeof this._sync.refreshRepo === 'function') { | |
| 355 | try { this._sync.refreshRepo(entry.name) } catch {} | |
| 356 | } | |
| 346 | 357 | ok = true |
| 347 | 358 | } catch (e) { |
| 348 | 359 | err = e |
| … | … | @@ -0,0 +1,179 @@ |
| 1 | /** | |
| 2 | * Replicated last-activity signal for a repo — the ordering key for /repos. | |
| 3 | * | |
| 4 | * ## Why not the filesystem | |
| 5 | * | |
| 6 | * The landing page used to order on hyper-sync's `lastUpdated`, the newest | |
| 7 | * mtime among HEAD, packed-refs and refs/** on the LOCAL disk. That is a | |
| 8 | * property of the machine, not of the repo, so the two backends behind | |
| 9 | * `7rnx.net/git` could not agree on the front page's order by construction. | |
| 10 | * Measured 2026-09-05: identical 135-repo sets on both, and all 135 with a | |
| 11 | * different `lastUpdated` — 67 apart by more than an hour, 61 by more than a | |
| 12 | * day. Three separate ways it drifts: | |
| 13 | * | |
| 14 | * 1. CLONE TIME. A repo nobody has pushed to since import carries the time | |
| 15 | * that host first fetched it. One bulk-import cohort reads 2026-06-03 on | |
| 16 | * tabertron and 2026-04-21 on stortron. | |
| 17 | * 2. STALENESS. `sync.repos` is a cache, and a mirror's pulls write inside | |
| 18 | * an existing `<repo>.git` where the non-recursive repos-dir watcher | |
| 19 | * cannot see them. See services/sync.js `attachRepoRefresh`. | |
| 20 | * 3. THE DAEMON ITSELF. The 5-minute reconcileAll sweep force-writes refs | |
| 21 | * from the tracker view onto disk. Materializing a ref bumps its mtime, | |
| 22 | * so a repo nothing happened to jumps to the top — ifsrenderer-linux went | |
| 23 | * from rank 20 to rank 1 on stortron with refs/heads/linux-port stamped | |
| 24 | * at the exact second of the sweep. | |
| 25 | * | |
| 26 | * ## What replaces it | |
| 27 | * | |
| 28 | * The per-repo tracker's ref-view already carries a `timestamp` per ref | |
| 29 | * (`listRefs()` -> `{ ref, hash, updatedBy, timestamp }`), stamped by the | |
| 30 | * writer that ACCEPTED the push and replicated verbatim. Every peer holding | |
| 31 | * the tracker derives the same number, so every backend renders the same | |
| 32 | * order. It also keeps the property the mtime was chosen for: force-pushing | |
| 33 | * an old commit is recent ACTIVITY, and unlike a git committer date it is | |
| 34 | * stamped by the daemon rather than supplied by the committer. | |
| 35 | * | |
| 36 | * ## Where the values come from | |
| 37 | * | |
| 38 | * RefReconciler calls `listRefs()` on every pass anyway — event-driven off the | |
| 39 | * tracker's `update`, plus the sweep — so recording `max(timestamp)` costs no | |
| 40 | * extra autobase work. The manager wires the hook; nothing here talks to an | |
| 41 | * autobase. | |
| 42 | * | |
| 43 | * Coverage is a boot walk: pear-git.js `ensureFor`s every `*.git` in the repos | |
| 44 | * dir at startup and `RefReconciler.start()` reconciles immediately, so every | |
| 45 | * repo with a mounted tracker has a value shortly after boot. A repo with no | |
| 46 | * tracker never gets one and orders on its mtime as before. | |
| 47 | * | |
| 48 | * Storage: <storage>/repo-ref-activity.json | |
| 49 | * { version: 1, repos: { <name>: <unix ms> } } | |
| 50 | * | |
| 51 | * Persisted for one reason: without it the first render after every restart | |
| 52 | * falls back to mtimes — the exact disagreement this fixes — because the boot | |
| 53 | * walk has not finished before the first visitor arrives. Losing the file is | |
| 54 | * harmless; the next reconcile pass rewrites every entry. | |
| 55 | */ | |
| 56 | ||
| 57 | import { readFile, writeFile, rename } from '#node:fs/promises' | |
| 58 | import { join } from '#node:path' | |
| 59 | ||
| 60 | const activity = new Map() // repo name -> unix ms | |
| 61 | ||
| 62 | let persistPath = null | |
| 63 | let loaded = false | |
| 64 | let dirty = false | |
| 65 | let saveTimer = null | |
| 66 | ||
| 67 | /** | |
| 68 | * Point the index at a storage dir and load persisted entries. Call once at | |
| 69 | * daemon boot before serving. Idempotent. | |
| 70 | * | |
| 71 | * @param {string} storageDir - daemon storage dir (opts.storage) | |
| 72 | */ | |
| 73 | export async function configureRefActivity (storageDir) { | |
| 74 | if (!storageDir) return | |
| 75 | persistPath = join(storageDir, 'repo-ref-activity.json') | |
| 76 | if (loaded) return | |
| 77 | loaded = true | |
| 78 | try { | |
| 79 | const obj = JSON.parse(await readFile(persistPath, 'utf8')) | |
| 80 | if (obj && obj.repos && typeof obj.repos === 'object') { | |
| 81 | for (const [name, ms] of Object.entries(obj.repos)) { | |
| 82 | if (typeof ms === 'number' && ms > 0) activity.set(name, ms) | |
| 83 | } | |
| 84 | } | |
| 85 | } catch { | |
| 86 | // No file yet (first boot), unreadable, or corrupt. Starting empty costs | |
| 87 | // one boot's worth of mtime ordering, not correctness. | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Record the newest ref timestamp seen for a repo. | |
| 93 | * | |
| 94 | * MONOTONIC. A later pass can legitimately see an OLDER max than an earlier | |
| 95 | * one — deleting the most recently pushed ref drops that entry out of the | |
| 96 | * view — and letting the value fall back would re-shuffle the page for a repo | |
| 97 | * whose activity plainly did not move backwards in time. | |
| 98 | * | |
| 99 | * @param {string} name - repo name | |
| 100 | * @param {number} ms - unix ms, the max `timestamp` across the repo's refs | |
| 101 | * @returns {boolean} whether the stored value advanced | |
| 102 | */ | |
| 103 | export function recordRefActivity (name, ms) { | |
| 104 | if (!name || typeof ms !== 'number' || !(ms > 0)) return false | |
| 105 | const prev = activity.get(name) | |
| 106 | if (typeof prev === 'number' && prev >= ms) return false | |
| 107 | activity.set(name, ms) | |
| 108 | dirty = true | |
| 109 | schedulePersist() | |
| 110 | return true | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * @param {string} name - repo name | |
| 115 | * @returns {number|null} unix ms, or null when this daemon has no value | |
| 116 | */ | |
| 117 | export function getRefActivity (name) { | |
| 118 | if (!name) return null | |
| 119 | const ms = activity.get(name) | |
| 120 | return typeof ms === 'number' ? ms : null | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Annotate repo records with `lastRefUpdate` where one is known. | |
| 125 | * | |
| 126 | * A repo with no value is returned UNCHANGED rather than carrying an explicit | |
| 127 | * null, so `repoRecency()` can tell "no replicated signal, use the mtime" | |
| 128 | * apart from "replicated signal of zero". | |
| 129 | * | |
| 130 | * @param {Array<object>} repos - records from sync.status() | |
| 131 | * @returns {Array<object>} | |
| 132 | */ | |
| 133 | export function mergeRefActivity (repos) { | |
| 134 | if (!Array.isArray(repos)) return [] | |
| 135 | return repos.map((r) => { | |
| 136 | const ms = r && r.name ? getRefActivity(r.name) : null | |
| 137 | return ms === null ? r : { ...r, lastRefUpdate: ms } | |
| 138 | }) | |
| 139 | } | |
| 140 | ||
| 141 | /** Force any pending persist immediately (graceful shutdown + tests). */ | |
| 142 | export async function flushRefActivity () { | |
| 143 | if (saveTimer) { clearTimeout(saveTimer); saveTimer = null } | |
| 144 | await persistNow() | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * Drop all process-local state so configureRefActivity() re-reads the file — | |
| 149 | * i.e. model a daemon restart within one process. Test seam only; it does NOT | |
| 150 | * touch the file. (A test cannot get a fresh copy of a module under Bare, | |
| 151 | * which resolves by realpath and ignores an import query — same reason | |
| 152 | * repo-activity.js carries resetRepoActivityCacheForTests.) | |
| 153 | */ | |
| 154 | export function resetRefActivityForTests () { | |
| 155 | activity.clear() | |
| 156 | persistPath = null | |
| 157 | loaded = false | |
| 158 | dirty = false | |
| 159 | if (saveTimer) { clearTimeout(saveTimer); saveTimer = null } | |
| 160 | } | |
| 161 | ||
| 162 | function schedulePersist () { | |
| 163 | if (!persistPath || saveTimer) return | |
| 164 | saveTimer = setTimeout(() => { saveTimer = null; persistNow().catch(() => {}) }, 1000) | |
| 165 | if (saveTimer && typeof saveTimer.unref === 'function') saveTimer.unref() | |
| 166 | } | |
| 167 | ||
| 168 | async function persistNow () { | |
| 169 | if (!persistPath || !dirty) return | |
| 170 | dirty = false | |
| 171 | const repos = Object.fromEntries(activity) | |
| 172 | const tmp = persistPath + '.tmp' | |
| 173 | try { | |
| 174 | await writeFile(tmp, JSON.stringify({ version: 1, repos })) | |
| 175 | await rename(tmp, persistPath) | |
| 176 | } catch { | |
| 177 | dirty = true // best-effort; retry on the next change | |
| 178 | } | |
| 179 | } |
| … | … | @@ -35,14 +35,73 @@ import { watch, existsSync } from '#node:fs' |
| 35 | 35 | import { join } from '#node:path' |
| 36 | 36 | |
| 37 | 37 | import HyperSync from 'hyper-sync' |
| 38 | // Deep import: hyper-sync exposes scanRepos/repoInfo/readRefs from this | |
| 39 | // module but re-exports none of them from its index. repoInfo is the | |
| 40 | // per-repo half of scanRepos — see attachRepoRefresh below for why the | |
| 41 | // whole-dir version is the wrong tool after a single pull. | |
| 42 | import hyperSyncRepos from 'hyper-sync/lib/repos.js' | |
| 38 | 43 | import Hyperswarm from 'hyperswarm' |
| 39 | 44 | |
| 40 | 45 | import { enrichRepos } from './repo-activity.js' |
| 46 | import { mergeRefActivity } from './repo-ref-activity.js' | |
| 41 | 47 | // execFile (async), not execFileSync — this runs inside the daemon's event loop |
| 42 | 48 | // and spawnSync would stall every in-flight HTTP request for the child's life. |
| 43 | 49 | import { execFile } from '../_compat-exec.js' |
| 44 | 50 | import { ensureExportMarker } from '../repo-access.js' |
| 45 | 51 | |
| 52 | /** | |
| 53 | * Give a hyper-sync server a `refreshRepo(name)` — re-stat ONE repo and | |
| 54 | * update its entry in the in-memory map that status() serves. | |
| 55 | * | |
| 56 | * WHY THIS EXISTS. `sync.repos` is a cache, and after boot almost nothing | |
| 57 | * refreshes it: | |
| 58 | * | |
| 59 | * - the repos-dir watcher below is a NON-RECURSIVE `watch(reposDir)`, so it | |
| 60 | * fires when a repo directory appears or disappears and never when refs | |
| 61 | * are written INSIDE an existing `<repo>.git`; | |
| 62 | * - RepoPuller fetches into existing repos continuously on a mirror and its | |
| 63 | * only hook is onMount -> scheduleMirrorReannounce. | |
| 64 | * | |
| 65 | * So a mirror served boot-time branch counts, pack sizes and lastUpdated for | |
| 66 | * repos it had since pulled. Measured on the live fleet 2026-09-05: stortron, | |
| 67 | * up 3 days, reported blender-bridge lastUpdated 2026-06-12 while its own disk | |
| 68 | * said 2026-09-04 15:57 — refs 2 seconds behind the origin's. Since /repos | |
| 69 | * orders on lastUpdated, four actively-pushed repos sat mid-page on one | |
| 70 | * backend and top-of-page on the other. | |
| 71 | * | |
| 72 | * PER-REPO, deliberately. The obvious fix is `sync.scan()`, but scanRepos() | |
| 73 | * walks every repo SYNCHRONOUSLY (364ms of a 409ms scan across 147 live | |
| 74 | * repos) and a mirror pulls repos one at a time — one full walk per pulled | |
| 75 | * repo would stall the event loop through the whole drain. repoInfo() is the | |
| 76 | * same computation for a single repo, and it takes the previous record so the | |
| 77 | * dirSize walk is skipped when the footprint has not moved. | |
| 78 | * | |
| 79 | * Only refreshes repos ALREADY in the map: adding one is the watcher's job | |
| 80 | * (it sees the new directory) and going through here would bypass the | |
| 81 | * retired-repo prune that every scan path applies. | |
| 82 | * | |
| 83 | * @param {object} sync - the hyper-sync server | |
| 84 | * @param {string} reposDir - bare-repos directory | |
| 85 | */ | |
| 86 | export function attachRepoRefresh (sync, reposDir) { | |
| 87 | sync.refreshRepo = (name) => { | |
| 88 | if (!name || !sync.repos || typeof sync.repos.get !== 'function') return null | |
| 89 | const prev = sync.repos.get(name) | |
| 90 | if (!prev) return null | |
| 91 | try { | |
| 92 | const info = hyperSyncRepos.repoInfo(join(reposDir, name + '.git'), name, prev) | |
| 93 | if (!info) return null | |
| 94 | sync.repos.set(name, info) | |
| 95 | return info | |
| 96 | } catch { | |
| 97 | // A repo that vanished mid-pull, or an unreadable dir. The stale entry | |
| 98 | // is better than dropping the repo out of the listing entirely. | |
| 99 | return null | |
| 100 | } | |
| 101 | } | |
| 102 | return sync | |
| 103 | } | |
| 104 | ||
| 46 | 105 | export async function startSyncService (opts) { |
| 47 | 106 | const { reposDir, keypair, hyperswarmOpts } = opts |
| 48 | 107 | if (!reposDir) throw new Error('sync service requires reposDir') |
| … | … | @@ -158,6 +217,10 @@ export async function startSyncService (opts) { |
| 158 | 217 | // the caller pays nothing for the disk scan it does not need. |
| 159 | 218 | sync.pruneRetired = pruneRetired |
| 160 | 219 | |
| 220 | // Per-repo re-stat, for the paths that change ONE repo's bytes without | |
| 221 | // touching the repos dir itself — RepoPuller's fetch above all. | |
| 222 | attachRepoRefresh(sync, reposDir) | |
| 223 | ||
| 161 | 224 | // Populate the in-memory map. We don't call serve() here — the caller |
| 162 | 225 | // attaches listeners (repoEventLog's on('serve') hook, etc.) AFTER we |
| 163 | 226 | // return, so serve() must run after listener wire-up or the initial |
| … | … | @@ -220,7 +283,11 @@ export function syncHandlers (svc) { |
| 220 | 283 | const sync = svc._sync |
| 221 | 284 | if (!sync) return { repos: [], active: false } |
| 222 | 285 | const status = sync.status() |
| 223 | return { active: true, ...status } | |
| 286 | // Annotate with the REPLICATED last-ref-update timestamp, which is what | |
| 287 | // /repos orders on. status() only carries the local mtime, and that is | |
| 288 | // a property of this machine — the two load-balanced backends never | |
| 289 | // agreed on it. See services/repo-ref-activity.js. | |
| 290 | return { active: true, ...status, repos: mergeRefActivity(status.repos) } | |
| 224 | 291 | } |
| 225 | 292 | }, |
| 226 | 293 | |
| … | … | @@ -244,11 +311,13 @@ export function syncHandlers (svc) { |
| 244 | 311 | const sync = svc._sync |
| 245 | 312 | if (!sync) return { repos: [], active: false } |
| 246 | 313 | const status = sync.status() |
| 247 | let subject = status | |
| 314 | let subject = { ...status, repos: mergeRefActivity(status.repos) } | |
| 248 | 315 | if (Array.isArray(params.names)) { |
| 249 | 316 | const wanted = new Set(params.names) |
| 250 | subject = { ...status, repos: status.repos.filter((r) => wanted.has(r.name)) } | |
| 317 | subject = { ...subject, repos: subject.repos.filter((r) => wanted.has(r.name)) } | |
| 251 | 318 | } |
| 319 | // enrichRepos spreads the base record through, so lastRefUpdate rides | |
| 320 | // along and the fragment route orders the same way the first page did. | |
| 252 | 321 | const repos = await enrichRepos(subject, sync.repos) |
| 253 | 322 | return { active: true, ...status, repos } |
| 254 | 323 | } |
| … | … | @@ -113,23 +113,36 @@ function renderActivityGraph (data) { |
| 113 | 113 | * Exported so the /repos page and the /api/repos/page fragment route order |
| 114 | 114 | * identically — a mismatch would make paging skip or repeat repos. |
| 115 | 115 | * |
| 116 | * Ordered on `lastUpdated` (newest ref/HEAD/packed-refs mtime), which | |
| 117 | * status() already has in memory, NOT on `lastCommit.timestamp`. lastCommit | |
| 118 | * only exists after enrichRepos() git-walks a repo's full history, so using | |
| 119 | * it as the sort key forced walking every repo to pick a page of 25. | |
| 116 | * NOT on `lastCommit.timestamp`: that only exists after enrichRepos() git-walks | |
| 117 | * a repo's full history, so using it as the sort key forced walking every repo | |
| 118 | * to pick a page of 25. It is also the wrong signal — force-pushing an old | |
| 119 | * commit IS recent activity, and committer dates are clock-controlled, so one | |
| 120 | * future-dated commit used to pin a repo to the top permanently. | |
| 120 | 121 | * |
| 121 | * It is also the better signal for "most recently pushed": force-pushing an | |
| 122 | * old commit IS recent activity, and committer dates are clock-controlled, | |
| 123 | * so one future-dated commit used to pin a repo to the top permanently. | |
| 122 | * PREFER `lastRefUpdate` — the newest `timestamp` in the repo's replicated | |
| 123 | * tracker ref-view (services/repo-ref-activity.js). Every backend derives the | |
| 124 | * same number from the same autobase, so `7rnx.net/git` renders one order | |
| 125 | * whichever backend the load balancer picks. | |
| 126 | * | |
| 127 | * FALL BACK to `lastUpdated`, the local ref/HEAD/packed-refs mtime that | |
| 128 | * status() already has in memory. It is what a repo with no mounted tracker | |
| 129 | * has, but it is a property of the MACHINE: on the live fleet all 135 repos | |
| 130 | * carried a different value on the two backends (67 more than an hour apart), | |
| 131 | * because a quiet repo's mtime records when THAT host cloned it and the | |
| 132 | * 5-minute reconcile sweep re-stamps refs it materializes. Mixing the two | |
| 133 | * scales is deliberate — both are unix ms, and a repo with no replicated | |
| 134 | * signal should sit where its local evidence puts it rather than sink below | |
| 135 | * everything that has one. | |
| 124 | 136 | * |
| 125 | 137 | * Branch count stands in for "has commits" — free from status(), whereas a |
| 126 | 138 | * commit count is not. Reads correctly on both cheap and enriched records, |
| 127 | 139 | * since enrichRepos spreads the base fields through. |
| 128 | 140 | */ |
| 129 | 141 | function repoRecency (r) { |
| 130 | // Milliseconds from whichever signal the record carries. lastUpdated is an | |
| 131 | // mtime in ms; lastCommit.timestamp is git's %at in SECONDS (timeAgo treats | |
| 132 | // it that way too), so it is scaled rather than compared raw. | |
| 142 | // Milliseconds from whichever signal the record carries. lastRefUpdate and | |
| 143 | // lastUpdated are both unix ms; lastCommit.timestamp is git's %at in SECONDS | |
| 144 | // (timeAgo treats it that way too), so it is scaled rather than compared raw. | |
| 145 | if (r.lastRefUpdate) return r.lastRefUpdate | |
| 133 | 146 | if (r.lastUpdated) return r.lastUpdated |
| 134 | 147 | if (r.lastCommit && r.lastCommit.timestamp) return r.lastCommit.timestamp * 1000 |
| 135 | 148 | return 0 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.249", | |
| 3 | "version": "0.1.250", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,112 @@ |
| 1 | // A mirror's repo listing went stale the moment a pull landed. | |
| 2 | // | |
| 3 | // RepoPuller._process fetches into an EXISTING bare repo. Nothing rescans | |
| 4 | // afterwards: the repos-dir watcher (services/sync.js) is a non-recursive | |
| 5 | // watch(reposDir), so writes INSIDE <repo>.git never wake it, and the | |
| 6 | // puller's only hook is onMount → scheduleMirrorReannounce. So | |
| 7 | // sync.status() — which feeds list-repos and the /repos ordering — kept | |
| 8 | // serving whatever the last full scan saw. | |
| 9 | // | |
| 10 | // Measured on the live fleet 2026-09-05: stortron (up 3d) reported | |
| 11 | // blender-bridge lastUpdated 2026-06-12 while its OWN disk said | |
| 12 | // 2026-09-04 15:57 — the refs were 2s behind tabertron's. Four repos were | |
| 13 | // buried on one backend and top-of-page on the other for this reason alone. | |
| 14 | // | |
| 15 | // Fix: refresh THAT repo's record after a successful pull. Per-repo, not a | |
| 16 | // full sync.scan() — scanRepos walks every repo synchronously (364ms across | |
| 17 | // 147 live repos) and would stall the event loop once per pulled repo. | |
| 18 | ||
| 19 | import test from 'brittle' | |
| 20 | import fs from '#node:fs' | |
| 21 | import os from '#node:os' | |
| 22 | import { join } from '#node:path' | |
| 23 | import { RepoPuller } from '../lib/services/repo-mirror.js' | |
| 24 | import { attachRepoRefresh } from '../lib/services/sync.js' | |
| 25 | ||
| 26 | function tmpdir (t) { | |
| 27 | const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refresh-')) | |
| 28 | t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} }) | |
| 29 | return dir | |
| 30 | } | |
| 31 | ||
| 32 | function bareRepo (dir, name, sha) { | |
| 33 | const p = join(dir, name + '.git') | |
| 34 | fs.mkdirSync(join(p, 'refs', 'heads'), { recursive: true }) | |
| 35 | fs.mkdirSync(join(p, 'objects', 'pack'), { recursive: true }) | |
| 36 | fs.writeFileSync(join(p, 'HEAD'), 'ref: refs/heads/main\n') | |
| 37 | fs.writeFileSync(join(p, 'refs', 'heads', 'main'), sha + '\n') | |
| 38 | return p | |
| 39 | } | |
| 40 | ||
| 41 | test('RepoPuller refreshes the pulled repo, and only on success', async (t) => { | |
| 42 | const ok = [] | |
| 43 | const good = new RepoPuller({ | |
| 44 | sync: { | |
| 45 | pull: async () => {}, | |
| 46 | remotes: new Map(), | |
| 47 | addRemote () {}, | |
| 48 | refreshRepo: (name) => ok.push(name) | |
| 49 | }, | |
| 50 | issueRegistry: {}, | |
| 51 | log: { close: async () => {} }, | |
| 52 | primaryServerKey: 'c'.repeat(64) | |
| 53 | }) | |
| 54 | await good._process({ name: 'demo', op: 0 }, 0) | |
| 55 | t.alike(ok, ['demo'], 'refreshRepo called with the repo name on success') | |
| 56 | ||
| 57 | const bad = [] | |
| 58 | const fails = new RepoPuller({ | |
| 59 | sync: { | |
| 60 | pull: async () => { throw new Error('boom') }, | |
| 61 | remotes: new Map(), | |
| 62 | addRemote () {}, | |
| 63 | refreshRepo: (name) => bad.push(name) | |
| 64 | }, | |
| 65 | issueRegistry: {}, | |
| 66 | log: { close: async () => {} }, | |
| 67 | primaryServerKey: 'd'.repeat(64) | |
| 68 | }) | |
| 69 | await fails._process({ name: 'nope', op: 0 }, 0) | |
| 70 | t.alike(bad, [], 'refreshRepo NOT called when the pull fails') | |
| 71 | }) | |
| 72 | ||
| 73 | test('RepoPuller survives a sync without refreshRepo (older hyper-sync)', async (t) => { | |
| 74 | const puller = new RepoPuller({ | |
| 75 | sync: { pull: async () => {}, remotes: new Map(), addRemote () {} }, | |
| 76 | issueRegistry: {}, | |
| 77 | log: { close: async () => {} }, | |
| 78 | primaryServerKey: 'e'.repeat(64) | |
| 79 | }) | |
| 80 | await puller._process({ name: 'demo', op: 0 }, 0) | |
| 81 | t.pass('no throw when the sync server has no refreshRepo') | |
| 82 | }) | |
| 83 | ||
| 84 | test('attachRepoRefresh re-stats ONE repo without walking the others', async (t) => { | |
| 85 | const dir = tmpdir(t) | |
| 86 | bareRepo(dir, 'alpha', 'a'.repeat(40)) | |
| 87 | bareRepo(dir, 'beta', 'b'.repeat(40)) | |
| 88 | ||
| 89 | // Model a daemon whose in-memory map is stale for alpha: an old lastUpdated | |
| 90 | // and no branches, exactly what a post-boot pull leaves behind. | |
| 91 | const stale = { name: 'alpha', path: join(dir, 'alpha.git'), branches: [], branchCount: 0, head: null, packSize: 0, lastUpdated: 1, footprint: 0 } | |
| 92 | const betaEntry = { name: 'beta', path: join(dir, 'beta.git'), branches: [], branchCount: 0, head: null, packSize: 0, lastUpdated: 2, footprint: 0 } | |
| 93 | const sync = { _reposDir: dir, repos: new Map([['alpha', stale], ['beta', betaEntry]]) } | |
| 94 | ||
| 95 | attachRepoRefresh(sync, dir) | |
| 96 | const updated = sync.refreshRepo('alpha') | |
| 97 | ||
| 98 | t.ok(updated, 'returns the refreshed record') | |
| 99 | t.is(sync.repos.get('alpha').branchCount, 1, 'alpha picked up its branch') | |
| 100 | t.is(sync.repos.get('alpha').head, 'a'.repeat(40), 'alpha picked up its head') | |
| 101 | t.ok(sync.repos.get('alpha').lastUpdated > 1, 'alpha lastUpdated moved to the real ref mtime') | |
| 102 | t.is(sync.repos.get('beta'), betaEntry, 'beta was left untouched — not a full rescan') | |
| 103 | }) | |
| 104 | ||
| 105 | test('attachRepoRefresh is a no-op for an unknown or vanished repo', async (t) => { | |
| 106 | const dir = tmpdir(t) | |
| 107 | const sync = { _reposDir: dir, repos: new Map() } | |
| 108 | attachRepoRefresh(sync, dir) | |
| 109 | t.is(sync.refreshRepo('ghost'), null, 'unknown repo returns null') | |
| 110 | t.is(sync.refreshRepo(''), null, 'empty name returns null') | |
| 111 | t.is(sync.repos.size, 0, 'nothing was added to the map') | |
| 112 | }) |
| … | … | @@ -0,0 +1,110 @@ |
| 1 | // RefReconciler reports the repo's newest REPLICATED ref timestamp. | |
| 2 | // | |
| 3 | // /repos orders on this instead of the local mtime — see | |
| 4 | // services/repo-ref-activity.js for the full why. What matters here is that | |
| 5 | // the reporting is FREE: _reconcileOnce already calls listRefs(), which is the | |
| 6 | // `base.update()` plus ref range-scan the whole sweep-skip machinery exists to | |
| 7 | // avoid repeating. Taking max(timestamp) off the array it already has adds no | |
| 8 | // autobase work at all. | |
| 9 | // | |
| 10 | // It must also fire on a NO-OP pass. The value is a property of the view, not | |
| 11 | // of whether this daemon's disk changed — on a mirror that is already in sync | |
| 12 | // (the common case, and exactly the backend the LB fronts) every pass is a | |
| 13 | // no-op, and gating on `changed` like onChange does would leave the ordering | |
| 14 | // key permanently unset there. | |
| 15 | ||
| 16 | import test from 'brittle' | |
| 17 | import { mkdtempSync, writeFileSync } from '#node:fs' | |
| 18 | import { join } from '#node:path' | |
| 19 | import { tmpdir } from '#node:os' | |
| 20 | ||
| 21 | import { RefReconciler } from '../lib/services/ref-reconciler.js' | |
| 22 | import { execFileSync } from '../lib/_compat-exec.js' | |
| 23 | ||
| 24 | function git (args, opts = {}) { | |
| 25 | return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim() | |
| 26 | } | |
| 27 | ||
| 28 | function fakeTracker (refs) { | |
| 29 | return { | |
| 30 | base: { on () {}, off () {} }, | |
| 31 | async listRefs () { return refs.slice() } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | function makeRepo () { | |
| 36 | const dir = mkdtempSync(join(tmpdir(), 'pgv3-refact-rec-')) | |
| 37 | const bare = join(dir, 'repo.git') | |
| 38 | const work = join(dir, 'work') | |
| 39 | git(['init', '--bare', '-q', bare]) | |
| 40 | git(['init', '-q', work]) | |
| 41 | git(['-C', work, 'config', 'user.email', 't@t']) | |
| 42 | git(['-C', work, 'config', 'user.name', 'T']) | |
| 43 | git(['-C', work, 'config', 'commit.gpgsign', 'false']) | |
| 44 | writeFileSync(join(work, 'a.txt'), 'one\n') | |
| 45 | git(['-C', work, 'add', '.']) | |
| 46 | git(['-C', work, 'commit', '-q', '-m', 'c1']) | |
| 47 | const o1 = git(['-C', work, 'rev-parse', 'HEAD']) | |
| 48 | git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main']) | |
| 49 | return { bare, o1 } | |
| 50 | } | |
| 51 | ||
| 52 | test('reports the newest ref timestamp across the view', async (t) => { | |
| 53 | const { bare, o1 } = makeRepo() | |
| 54 | const seen = [] | |
| 55 | const tracker = fakeTracker([ | |
| 56 | { ref: 'refs/heads/main', hash: o1, timestamp: 1000 }, | |
| 57 | { ref: 'refs/heads/old', hash: o1, timestamp: 500 }, | |
| 58 | { ref: 'refs/heads/new', hash: o1, timestamp: 7000 } | |
| 59 | ]) | |
| 60 | const rec = new RefReconciler({ | |
| 61 | tracker, | |
| 62 | repoPath: bare, | |
| 63 | onRefActivity: (ms) => seen.push(ms) | |
| 64 | }) | |
| 65 | await rec.reconcile() | |
| 66 | t.alike(seen, [7000], 'the max, not the first or the last') | |
| 67 | }) | |
| 68 | ||
| 69 | test('fires on a no-op pass — a synced mirror must still report', async (t) => { | |
| 70 | const { bare, o1 } = makeRepo() | |
| 71 | const seen = [] | |
| 72 | // The bare repo is ALREADY at o1, so this pass changes nothing on disk. | |
| 73 | const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1, timestamp: 4242 }]) | |
| 74 | const rec = new RefReconciler({ tracker, repoPath: bare, onRefActivity: (ms) => seen.push(ms) }) | |
| 75 | await rec.reconcile() | |
| 76 | t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1, 'nothing moved on disk') | |
| 77 | t.alike(seen, [4242], 'reported anyway') | |
| 78 | }) | |
| 79 | ||
| 80 | test('stays silent when the view carries no usable timestamp', async (t) => { | |
| 81 | const { bare, o1 } = makeRepo() | |
| 82 | const seen = [] | |
| 83 | // A ref-view written by a build that predates the timestamp field, or an | |
| 84 | // empty view. Reporting 0 would rank the repo below everything. | |
| 85 | const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }]) | |
| 86 | const rec = new RefReconciler({ tracker, repoPath: bare, onRefActivity: (ms) => seen.push(ms) }) | |
| 87 | await rec.reconcile() | |
| 88 | t.alike(seen, [], 'no timestamp, no report') | |
| 89 | ||
| 90 | const empty = new RefReconciler({ | |
| 91 | tracker: fakeTracker([]), | |
| 92 | repoPath: bare, | |
| 93 | onRefActivity: (ms) => seen.push(ms) | |
| 94 | }) | |
| 95 | await empty.reconcile() | |
| 96 | t.alike(seen, [], 'empty view, no report') | |
| 97 | }) | |
| 98 | ||
| 99 | test('a failing onRefActivity never breaks reconciliation', async (t) => { | |
| 100 | const { bare, o1 } = makeRepo() | |
| 101 | git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/main']) | |
| 102 | const rec = new RefReconciler({ | |
| 103 | tracker: fakeTracker([{ ref: 'refs/heads/main', hash: o1, timestamp: 1 }]), | |
| 104 | repoPath: bare, | |
| 105 | onRefActivity: () => { throw new Error('index exploded') } | |
| 106 | }) | |
| 107 | await rec.reconcile() | |
| 108 | t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1, | |
| 109 | 'the ref was still materialized despite the hook throwing') | |
| 110 | }) |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | // Ordering /repos on a REPLICATED signal instead of a local file mtime. | |
| 2 | // | |
| 3 | // THE BUG. sortReposByRecency ordered on `lastUpdated`, which hyper-sync | |
| 4 | // derives from the mtime of HEAD / packed-refs / refs/** on the local disk | |
| 5 | // (hyper-sync/lib/repos.js). That is a per-machine number, so the two | |
| 6 | // load-balanced backends of 7rnx.net/git could not agree by construction. | |
| 7 | // Measured 2026-09-05: the same 135 repos on both, and all 135 with a | |
| 8 | // different lastUpdated — 67 differing by more than an hour. Whichever | |
| 9 | // backend the LB picked decided the front page's order. | |
| 10 | // | |
| 11 | // Three independent ways it drifts: | |
| 12 | // 1. clone time. A repo nobody has pushed to since import carries the time | |
| 13 | // THAT host first fetched it (2026-06-03 on tabertron, 2026-04-21 on | |
| 14 | // stortron for one bulk-import cohort). | |
| 15 | // 2. staleness. See mirror-pull-refresh.test.js. | |
| 16 | // 3. self-inflicted. The 5-minute reconcileAll sweep force-writes refs from | |
| 17 | // the tracker view onto disk, bumping the mtime of a repo nothing | |
| 18 | // happened to. ifsrenderer-linux went from rank 20 to rank 1 on stortron | |
| 19 | // that way, with refs/heads/linux-port stamped at the exact second of the | |
| 20 | // sweep. | |
| 21 | // | |
| 22 | // THE FIX. The per-repo tracker's ref-view already carries a `timestamp` per | |
| 23 | // ref, stamped by the writer that ACCEPTED the push and replicated verbatim | |
| 24 | // to every peer. RefReconciler reads that view on every pass anyway, so | |
| 25 | // recording max(timestamp) costs no extra autobase work, and every backend | |
| 26 | // that replicates the tracker derives the identical number. | |
| 27 | ||
| 28 | import test from 'brittle' | |
| 29 | import fs from '#node:fs' | |
| 30 | import os from '#node:os' | |
| 31 | import { join } from '#node:path' | |
| 32 | import { | |
| 33 | recordRefActivity, getRefActivity, mergeRefActivity, | |
| 34 | configureRefActivity, flushRefActivity, resetRefActivityForTests | |
| 35 | } from '../lib/services/repo-ref-activity.js' | |
| 36 | import { sortReposByRecency } from '../lib/web/pages/repos.js' | |
| 37 | ||
| 38 | function tmpdir (t) { | |
| 39 | const dir = fs.mkdtempSync(join(os.tmpdir(), 'pgv3-refact-')) | |
| 40 | t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} }) | |
| 41 | return dir | |
| 42 | } | |
| 43 | ||
| 44 | test('records the newest ref timestamp and never moves backwards', async (t) => { | |
| 45 | resetRefActivityForTests() | |
| 46 | t.teardown(resetRefActivityForTests) | |
| 47 | ||
| 48 | t.is(getRefActivity('demo'), null, 'unknown repo has no value') | |
| 49 | ||
| 50 | recordRefActivity('demo', 1000) | |
| 51 | t.is(getRefActivity('demo'), 1000, 'first value recorded') | |
| 52 | ||
| 53 | recordRefActivity('demo', 5000) | |
| 54 | t.is(getRefActivity('demo'), 5000, 'advances') | |
| 55 | ||
| 56 | // A pass can see an OLDER max than a previous pass: a ref deletion drops the | |
| 57 | // newest entry out of the view. That is not a reason to un-know the repo's | |
| 58 | // activity, and letting it fall back would re-shuffle the page. | |
| 59 | recordRefActivity('demo', 2000) | |
| 60 | t.is(getRefActivity('demo'), 5000, 'never regresses') | |
| 61 | ||
| 62 | recordRefActivity('demo', 0) | |
| 63 | recordRefActivity('demo', null) | |
| 64 | recordRefActivity('', 9999) | |
| 65 | t.is(getRefActivity('demo'), 5000, 'zero/null/nameless are ignored') | |
| 66 | }) | |
| 67 | ||
| 68 | test('mergeRefActivity annotates repo records without inventing fields', async (t) => { | |
| 69 | resetRefActivityForTests() | |
| 70 | t.teardown(resetRefActivityForTests) | |
| 71 | ||
| 72 | recordRefActivity('alpha', 4242) | |
| 73 | const out = mergeRefActivity([{ name: 'alpha' }, { name: 'beta' }]) | |
| 74 | t.is(out[0].lastRefUpdate, 4242, 'known repo annotated') | |
| 75 | t.absent('lastRefUpdate' in out[1], 'unknown repo left alone') | |
| 76 | t.alike(mergeRefActivity(null), [], 'non-array input is safe') | |
| 77 | }) | |
| 78 | ||
| 79 | test('the replicated timestamp outranks the local mtime', async (t) => { | |
| 80 | resetRefActivityForTests() | |
| 81 | t.teardown(resetRefActivityForTests) | |
| 82 | ||
| 83 | // `quiet` looks fresher on disk — this is the reconcile-sweep bump, or a | |
| 84 | // late clone. `busy` is the one that actually got a push. | |
| 85 | const repos = [ | |
| 86 | { name: 'quiet', branches: 1, lastUpdated: 9_000_000, lastRefUpdate: 1_000_000 }, | |
| 87 | { name: 'busy', branches: 1, lastUpdated: 1_000, lastRefUpdate: 8_000_000 } | |
| 88 | ] | |
| 89 | t.alike(sortReposByRecency(repos).map((r) => r.name), ['busy', 'quiet'], | |
| 90 | 'ordered by the replicated ref timestamp, not the mtime') | |
| 91 | ||
| 92 | // Two backends whose mtimes disagree wildly still agree on the order. | |
| 93 | const backendA = [ | |
| 94 | { name: 'a', branches: 1, lastUpdated: 1, lastRefUpdate: 300 }, | |
| 95 | { name: 'b', branches: 1, lastUpdated: 999_999, lastRefUpdate: 200 }, | |
| 96 | { name: 'c', branches: 1, lastUpdated: 500, lastRefUpdate: 100 } | |
| 97 | ] | |
| 98 | const backendB = [ | |
| 99 | { name: 'a', branches: 1, lastUpdated: 777_777, lastRefUpdate: 300 }, | |
| 100 | { name: 'b', branches: 1, lastUpdated: 3, lastRefUpdate: 200 }, | |
| 101 | { name: 'c', branches: 1, lastUpdated: 42, lastRefUpdate: 100 } | |
| 102 | ] | |
| 103 | t.alike( | |
| 104 | sortReposByRecency(backendA).map((r) => r.name), | |
| 105 | sortReposByRecency(backendB).map((r) => r.name), | |
| 106 | 'both backends produce the same page order' | |
| 107 | ) | |
| 108 | }) | |
| 109 | ||
| 110 | test('falls back to the mtime when a repo has no replicated timestamp', async (t) => { | |
| 111 | // A repo whose tracker has never been mounted here (no reconciler, so no | |
| 112 | // value yet) must not sink below every repo that has one — it is ordered on | |
| 113 | // whatever local signal exists, as before. | |
| 114 | const repos = [ | |
| 115 | { name: 'tracked', branches: 1, lastRefUpdate: 5_000 }, | |
| 116 | { name: 'untracked-new', branches: 1, lastUpdated: 9_000 }, | |
| 117 | { name: 'untracked-old', branches: 1, lastUpdated: 1_000 } | |
| 118 | ] | |
| 119 | t.alike(sortReposByRecency(repos).map((r) => r.name), | |
| 120 | ['untracked-new', 'tracked', 'untracked-old'], | |
| 121 | 'mixed records order on one comparable scale') | |
| 122 | ||
| 123 | // Empty repos still sink regardless of which signal they carry. | |
| 124 | const withEmpty = [ | |
| 125 | { name: 'empty', branches: 0, lastRefUpdate: 9_999_999 }, | |
| 126 | { name: 'real', branches: 1, lastRefUpdate: 1 } | |
| 127 | ] | |
| 128 | t.alike(sortReposByRecency(withEmpty).map((r) => r.name), ['real', 'empty'], | |
| 129 | 'repos with no history still sink to the bottom') | |
| 130 | }) | |
| 131 | ||
| 132 | test('survives a restart — the value is persisted and reloaded', async (t) => { | |
| 133 | const dir = tmpdir(t) | |
| 134 | resetRefActivityForTests() | |
| 135 | t.teardown(resetRefActivityForTests) | |
| 136 | ||
| 137 | await configureRefActivity(dir) | |
| 138 | recordRefActivity('alpha', 12345) | |
| 139 | recordRefActivity('beta', 67890) | |
| 140 | await flushRefActivity() | |
| 141 | ||
| 142 | // Model a daemon restart: drop all process state, re-configure the same dir. | |
| 143 | resetRefActivityForTests() | |
| 144 | await configureRefActivity(dir) | |
| 145 | t.is(getRefActivity('alpha'), 12345, 'alpha survived the restart') | |
| 146 | t.is(getRefActivity('beta'), 67890, 'beta survived the restart') | |
| 147 | ||
| 148 | // Without this the first render after every restart falls back to mtimes, | |
| 149 | // which is exactly the disagreement being fixed — the boot ensureFor walk | |
| 150 | // repopulates it, but not before the first visitor arrives. | |
| 151 | t.ok(fs.existsSync(join(dir, 'repo-ref-activity.json')), 'persisted on disk') | |
| 152 | }) | |
| 153 | ||
| 154 | test('a corrupt or absent cache file is not fatal', async (t) => { | |
| 155 | const dir = tmpdir(t) | |
| 156 | resetRefActivityForTests() | |
| 157 | t.teardown(resetRefActivityForTests) | |
| 158 | ||
| 159 | fs.writeFileSync(join(dir, 'repo-ref-activity.json'), '{not json') | |
| 160 | await configureRefActivity(dir) | |
| 161 | t.is(getRefActivity('anything'), null, 'starts empty rather than throwing') | |
| 162 | ||
| 163 | recordRefActivity('alpha', 1) | |
| 164 | t.is(getRefActivity('alpha'), 1, 'still usable after a bad load') | |
| 165 | }) |