OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / 97270a6

0.1.6: lastUpdated tracks loose refs, not just HEAD/packed-refs
repoInfo computed lastUpdated as max(HEAD mtime, packed-refs mtime). A bare repo that has never been repacked keeps every branch as a loose file under refs/, and a push rewrites that file without touching either of those -- so lastUpdated reported ~repo-creation time for exactly those repos. On the live forge backend that is the majority: 92 of 147 repos have no packed-refs at all, and 69 of 99 checkable repos were over an hour stale against their own newest ref. Re-measured across all 147 after the fix: 122 repos move >1h forward, 0 remain stale. Now takes the newest of three independently-probed sources (HEAD, packed-refs, and a recursive walk of refs/). Probing them separately also fixes a second bug: packed-refs was nested inside HEAD's try, so a repo with no readable HEAD reported null even when its refs were fresh. The refs walk includes directory mtimes so a branch deletion registers, and costs ~nothing: full repoInfo across 147 repos measured 282ms. The field had no consumers anywhere (audited across hyper-sync, pear-git-v3 and pgh), so nothing can regress on the semantic change. It is groundwork for ordering the /repos landing page by a cheap key instead of git-walking every repo's full history just to sort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97270a66d49e98cf87c10f03162bae7eb70f4419
by plankatron <fleeky@gmail.com> · 2026-08-29T15:30:19.000Z

Changed files (3) +107 -11

lib/repos.js +57 -10
……@@ -63,6 +63,44 @@ function scanRepos (dir) {
6363 return repos;
6464}
6565
66/**
67 * Newest mtime anywhere under a repo's refs/ tree, or null if unreadable.
68 *
69 * Recursive: branches nest (refs/heads/feature/x) and tags sit alongside
70 * them, and a write to either is real repo activity. Directory mtimes count
71 * too — creating or deleting a ref restamps the containing directory even
72 * when no surviving file is newer, so a branch deletion still registers.
73 *
74 * Best-effort and synchronous, matching the rest of this file: anything
75 * unreadable is skipped rather than failing the scan.
76 */
77function newestRefMtime (dir) {
78 let newest = null;
79 let entries;
80 try {
81 entries = fs.readdirSync(dir, { withFileTypes: true });
82 } catch {
83 return null;
84 }
85 try {
86 const st = fs.statSync(dir);
87 newest = st.mtimeMs;
88 } catch {}
89 for (const entry of entries) {
90 const full = path.join(dir, entry.name);
91 if (entry.isDirectory()) {
92 const sub = newestRefMtime(full);
93 if (sub !== null && (newest === null || sub > newest)) newest = sub;
94 } else if (entry.isFile()) {
95 try {
96 const st = fs.statSync(full);
97 if (newest === null || st.mtimeMs > newest) newest = st.mtimeMs;
98 } catch {}
99 }
100 }
101 return newest;
102}
103
66104/**
67105 * Get info for a single bare repo.
68106 *
……@@ -91,18 +129,27 @@ function repoInfo (repoPath, name) {
91129 // on /repos because of `truthy ?` template check).
92130 const size = dirSize(repoPath);
93131
132 // Last-activity signal, as the newest of three independent sources. Each
133 // is probed separately so a missing one cannot suppress the others — the
134 // previous version nested packed-refs inside HEAD's try, so a repo with no
135 // readable HEAD reported null even when its refs had just been written.
136 //
137 // Loose refs are the load-bearing case. A bare repo that has never been
138 // repacked keeps every branch as a file under refs/, and a push rewrites
139 // that file WITHOUT touching HEAD or packed-refs. Reading only those two
140 // made lastUpdated report ~repo-creation time for exactly those repos —
141 // the majority on a real forge (92 of 147 on the live backend had no
142 // packed-refs at all, and 69 of 99 checkable repos were over an hour stale
143 // against their own newest ref), which makes it useless for ordering by
144 // recency. See test 'repoInfo.lastUpdated tracks loose refs'.
94145 let lastUpdated = null;
95 try {
96 // Check HEAD file mtime as proxy for last activity
97 const headStat = fs.statSync(path.join(repoPath, 'HEAD'));
98 lastUpdated = headStat.mtimeMs;
146 const bump = (ms) => {
147 if (typeof ms === 'number' && (lastUpdated === null || ms > lastUpdated)) lastUpdated = ms;
148 };
99149
100 // Better: check packed-refs mtime if it exists
101 try {
102 const prStat = fs.statSync(path.join(repoPath, 'packed-refs'));
103 if (prStat.mtimeMs > lastUpdated) lastUpdated = prStat.mtimeMs;
104 } catch {}
105 } catch {}
150 try { bump(fs.statSync(path.join(repoPath, 'HEAD')).mtimeMs); } catch {}
151 try { bump(fs.statSync(path.join(repoPath, 'packed-refs')).mtimeMs); } catch {}
152 bump(newestRefMtime(path.join(repoPath, 'refs')));
106153
107154 return {
108155 name,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-sync",
3 "version": "0.1.5",
3 "version": "0.1.6",
44 "description": "Manage and sync bare git repos over Hyperswarm",
55 "main": "index.js",
66 "imports": {
test/basic.test.js +49 -0
……@@ -79,6 +79,55 @@ test('repoInfo repoints dangling HEAD to first available branch (U40)', async (t
7979 t.is(readme, '# u40\n', 'git show HEAD:README.md works after repoint');
8080});
8181
82test('repoInfo.lastUpdated tracks loose refs, not just HEAD/packed-refs', async (t) => {
83 // Reproduces the live-forge shape: on the public backend 92 of 147 repos
84 // have NO packed-refs at all — every ref is a loose file under
85 // refs/heads/. lastUpdated was computed as max(HEAD mtime, packed-refs
86 // mtime), so for those repos it reported HEAD's mtime, which a push does
87 // not touch. The field then reads as ~repo-creation time forever: on the
88 // live fleet 69 of 99 checkable repos were >1h stale against their own
89 // newest ref, which makes lastUpdated useless as a recency signal.
90 //
91 // --initial-branch=main deliberately matches the pushed branch: with a
92 // dangling HEAD, repointHeadIfDangling() REWRITES HEAD during repoInfo(),
93 // freshening its mtime and hiding the very bug under test.
94 const { execSync } = require('child_process');
95 const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-lastupd-'));
96 const bareDir = path.join(reposRoot, 'lastupd-test.git');
97 execSync(`git init --bare --initial-branch=main ${bareDir}`, { stdio: 'ignore' });
98
99 const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-lastupd-work-'));
100 const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
101 g('init --initial-branch=main');
102 g('config user.email t@t');
103 g('config user.name T');
104 fs.writeFileSync(path.join(work, 'README.md'), '# lastupd\n');
105 g('add -A');
106 g('commit -m seed');
107 g(`push ${bareDir} main`);
108
109 // Prod shape: loose ref, no packed-refs.
110 const loosePath = path.join(bareDir, 'refs', 'heads', 'main');
111 t.ok(fs.existsSync(loosePath), 'ref is loose at refs/heads/main');
112 t.absent(fs.existsSync(path.join(bareDir, 'packed-refs')), 'no packed-refs (matches 92/147 live repos)');
113
114 // Pin both mtimes explicitly so the assertion cannot race the clock:
115 // HEAD is old (untouched since creation), the ref is recent (the push).
116 const OLD = new Date(Date.now() - 30 * 86400000);
117 const NEW = new Date();
118 fs.utimesSync(path.join(bareDir, 'HEAD'), OLD, OLD);
119 fs.utimesSync(loosePath, NEW, NEW);
120
121 const info = repoInfo(bareDir, 'lastupd-test');
122 t.ok(info, 'info returned');
123
124 // The push is the repo's last activity, so lastUpdated must reflect the
125 // ref write — not HEAD, which predates it by 30 days.
126 const skewMs = Math.abs(info.lastUpdated - NEW.getTime());
127 t.ok(skewMs < 2000, 'lastUpdated tracks the loose ref (skew ' + Math.round(skewMs) + 'ms)');
128 t.ok(info.lastUpdated > OLD.getTime() + 86400000, 'lastUpdated is not HEAD mtime');
129});
130
82131// ── Topic Derivation ────────────────────────────────────────
83132
84133test('deriveTopic is deterministic', async (t) => {