OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / a6ae032

0.1.7: carry packSize forward for unchanged repos on rescan
repoInfo called dirSize(), which recursively stats every file in the repo. Across the 147 live repos that walks 7.9GB and is ~89% of a scan (364ms of 409ms). scanRepos is SYNCHRONOUS, so that time is a hard event-loop stall for the whole daemon -- no HTTP served, no RPC answered -- and pgv3's repos-dir watcher re-runs a scan on every push. An untouched repo cannot have changed size, so a rescan now carries the previous packSize forward. Gated on a cheap footprint key: newest ref write (a push), objects/ (new loose-object subdirs), and objects/pack/ (a repack or gc) -- three stats instead of a full tree walk. Measured on the live 147: full scan 257ms -> memoized rescan 21ms, a 12.2x speedup, with 0 packSize mismatches against a full unconditional walk. The previous map is passed in by the caller (scanRepos(dir, { previous })}), NOT memoized in module state: scanRepos stays a pure function of its inputs, callers without `previous` behave exactly as before, and there is no hidden cache for a test or a second HyperSync instance to trip over. Deliberately not exhaustive: a gc that only prunes loose objects inside an existing objects/xx/ subdir, touching no ref and no pack, leaves a stale byte count until the repo next changes. packSize is display-only (a number on a card -- no logic reads it), so that is the right trade against stalling the event loop on every scan. Kept synchronous on purpose. Making dirSize async would make scan() async, which breaks the sync scanRepos/repoInfo export contract AND lets the watcher interleave two scans -- pgv3 prunes retired repos between scan() and serve(), so an interleaved scan could re-announce a tombstoned repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a6ae0320e08abf79a2e5a9ff3d29b4eee962d164
by plankatron <fleeky@gmail.com> · 2026-08-29T15:46:37.000Z

Changed files (4) +101 -8

index.js +2 -1
……@@ -97,7 +97,8 @@ module.exports = class HyperSync extends ReadyResource {
9797 // ── Repo Management ───────────────────────────────────────
9898
9999 async scan () {
100 this.repos = scanRepos(this._reposDir);
100 // Pass the previous map so unchanged repos skip the tree walk.
101 this.repos = scanRepos(this._reposDir, { previous: this.repos });
101102 this.emit('scan', this.repos);
102103 return this.repos;
103104 }
lib/repos.js +45 -6
……@@ -32,8 +32,16 @@ function dirSize (p) {
3232/**
3333 * Scan a directory for bare git repos.
3434 * Returns a Map of repo name → info.
35 *
36 * `opts.previous` is the Map from the last scan. Repos whose footprint key
37 * is unchanged carry their packSize forward instead of re-walking the tree,
38 * which is ~89% of the scan's cost and blocks the event loop while it runs.
39 * Omit it for a full, unconditional scan — the state stays with the caller,
40 * so this function has no hidden memo of its own and repeat calls without
41 * `previous` behave exactly as before.
3542 */
36function scanRepos (dir) {
43function scanRepos (dir, opts = {}) {
44 const previous = opts.previous instanceof Map ? opts.previous : null;
3745 const repos = new Map();
3846 let entries;
3947
……@@ -56,7 +64,7 @@ function scanRepos (dir) {
5664 continue;
5765 }
5866
59 const info = repoInfo(repoPath, name);
67 const info = repoInfo(repoPath, name, previous ? previous.get(name) : null);
6068 if (info) repos.set(name, info);
6169 }
6270
……@@ -112,7 +120,7 @@ function newestRefMtime (dir) {
112120 * HEAD to the first available branch — the SAME branch repoInfo
113121 * picks for the .head field, so downstream readers stay consistent.
114122 */
115function repoInfo (repoPath, name) {
123function repoInfo (repoPath, name, cached = null) {
116124 const refs = readRefs(repoPath);
117125 const branches = Object.keys(refs);
118126 const headBranch = refs.master ? 'master'
……@@ -121,13 +129,43 @@ function repoInfo (repoPath, name) {
121129 const head = headBranch ? refs[headBranch] : null;
122130 if (headBranch) repointHeadIfDangling(repoPath, refs, headBranch);
123131
132 // Computed once and used twice below: as the newest-activity signal, and
133 // as part of the footprint key.
134 const refsMtime = newestRefMtime(path.join(repoPath, 'refs'));
135
136 // Cheap fingerprint of the repo's on-disk footprint. Three stats stand in
137 // for a full tree walk: the newest ref write (a push), objects/ (new
138 // loose-object subdirs), and objects/pack/ (a repack or gc). Together
139 // those cover every way a repo normally gains or loses bytes.
140 let footprint = refsMtime || 0;
141 for (const rel of [['objects'], ['objects', 'pack']]) {
142 try {
143 const st = fs.statSync(path.join(repoPath, ...rel));
144 if (st.mtimeMs > footprint) footprint = st.mtimeMs;
145 } catch {}
146 }
147
124148 // Real bare-repo footprint: walk the whole dir. Counts loose objects
125149 // under objects/xx/, packs + idx + bitmap + rev, refs/, packed-refs,
126150 // info/, hooks/, etc. Previously only `objects/pack/*.pack` bytes were
127151 // counted — that misses freshly-fetched repos where content is still
128152 // loose, and produces 0 for many real repos (which then renders blank
129153 // on /repos because of `truthy ?` template check).
130 const size = dirSize(repoPath);
154 //
155 // That walk is ~89% of a scan (364ms of 409ms across 147 live repos,
156 // 7.9GB), and scanRepos is synchronous — so it stalls the daemon's event
157 // loop, and the repos-dir watcher re-runs it on every push. An untouched
158 // repo cannot have changed size, so carry the previous value forward when
159 // the footprint key matches.
160 //
161 // Deliberately not exhaustive: a gc that only prunes loose objects inside
162 // an existing objects/xx/ subdir, touching no ref and no pack, does not
163 // move the key, leaving a slightly stale byte count until the repo next
164 // changes. packSize is display-only, so that is the right trade against
165 // stalling the event loop on every scan.
166 const size = (cached && cached.footprint === footprint && typeof cached.packSize === 'number')
167 ? cached.packSize
168 : dirSize(repoPath);
131169
132170 // Last-activity signal, as the newest of three independent sources. Each
133171 // is probed separately so a missing one cannot suppress the others — the
……@@ -149,7 +187,7 @@ function repoInfo (repoPath, name) {
149187
150188 try { bump(fs.statSync(path.join(repoPath, 'HEAD')).mtimeMs); } catch {}
151189 try { bump(fs.statSync(path.join(repoPath, 'packed-refs')).mtimeMs); } catch {}
152 bump(newestRefMtime(path.join(repoPath, 'refs')));
190 bump(refsMtime);
153191
154192 return {
155193 name,
……@@ -158,7 +196,8 @@ function repoInfo (repoPath, name) {
158196 branchCount: branches.length,
159197 head,
160198 packSize: size,
161 lastUpdated
199 lastUpdated,
200 footprint
162201 };
163202}
164203
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-sync",
3 "version": "0.1.6",
3 "version": "0.1.7",
44 "description": "Manage and sync bare git repos over Hyperswarm",
55 "main": "index.js",
66 "imports": {
test/basic.test.js +53 -0
……@@ -128,6 +128,59 @@ test('repoInfo.lastUpdated tracks loose refs, not just HEAD/packed-refs', async
128128 t.ok(info.lastUpdated > OLD.getTime() + 86400000, 'lastUpdated is not HEAD mtime');
129129});
130130
131test('scanRepos reuses packSize for an unchanged repo', async (t) => {
132 // dirSize() recursively stats every file in every repo and is ~89% of a
133 // scan (364ms of 409ms across 147 live repos, walking 7.9GB). scanRepos is
134 // synchronous, so that time is a hard event-loop stall for the whole
135 // daemon -- and the repos-dir watcher re-runs it on every push.
136 //
137 // Nothing about an untouched repo's footprint can change between scans, so
138 // a rescan should carry the previous size forward instead of re-walking.
139 //
140 // Asserted behaviourally, not by timing: a file is added somewhere the
141 // change-key deliberately ignores, so REUSING the cache is observable as
142 // the size staying put, and invalidating it is observable as the size
143 // moving. packSize is display-only (a byte count on a card), so that
144 // window of staleness costs nothing.
145 const { execSync } = require('child_process');
146 const root = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-packsize-'));
147 const bareDir = path.join(root, 'sz.git');
148 execSync(`git init --bare --initial-branch=main ${bareDir}`, { stdio: 'ignore' });
149
150 const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-packsize-work-'));
151 const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
152 g('init --initial-branch=main');
153 g('config user.email t@t');
154 g('config user.name T');
155 fs.writeFileSync(path.join(work, 'README.md'), '# sz\n');
156 g('add -A');
157 g('commit -m seed');
158 g(`push ${bareDir} main`);
159
160 const m1 = scanRepos(root);
161 const size1 = m1.get('sz').packSize;
162 t.ok(size1 > 0, 'baseline size measured: ' + size1);
163
164 // info/ is inside the repo (so dirSize counts it) but is not refs/ or
165 // objects/, so it does not move the change-key.
166 fs.writeFileSync(path.join(bareDir, 'info', 'filler'), 'x'.repeat(20000));
167
168 const m2 = scanRepos(root, { previous: m1 });
169 t.is(m2.get('sz').packSize, size1, 'unchanged repo: size carried forward, tree not re-walked');
170
171 // Control: prove the filler really is counted, so the assertion above is
172 // meaningful rather than trivially true.
173 const m3 = scanRepos(root);
174 t.ok(m3.get('sz').packSize > size1, 'a fresh scan does see the added bytes');
175
176 // A ref write must invalidate: this is a real push's signature.
177 const future = new Date(Date.now() + 10000);
178 fs.utimesSync(path.join(bareDir, 'refs', 'heads', 'main'), future, future);
179
180 const m4 = scanRepos(root, { previous: m2 });
181 t.ok(m4.get('sz').packSize > size1, 'changed repo: re-walked and size updated');
182});
183
131184// ── Topic Derivation ────────────────────────────────────────
132185
133186test('deriveTopic is deterministic', async (t) => {