| … | … | @@ -63,6 +63,44 @@ function scanRepos (dir) { |
| 63 | 63 | return repos; |
| 64 | 64 | } |
| 65 | 65 | |
| 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 | */ | |
| 77 | function 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 | ||
| 66 | 104 | /** |
| 67 | 105 | * Get info for a single bare repo. |
| 68 | 106 | * |
| … | … | @@ -91,18 +129,27 @@ function repoInfo (repoPath, name) { |
| 91 | 129 | // on /repos because of `truthy ?` template check). |
| 92 | 130 | const size = dirSize(repoPath); |
| 93 | 131 | |
| 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'. | |
| 94 | 145 | 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 | }; | |
| 99 | 149 | |
| 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'))); | |
| 106 | 153 | |
| 107 | 154 | return { |
| 108 | 155 | name, |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-sync", |
| 3 | "version": "0.1.5", | |
| 3 | "version": "0.1.6", | |
| 4 | 4 | "description": "Manage and sync bare git repos over Hyperswarm", |
| 5 | 5 | "main": "index.js", |
| 6 | 6 | "imports": { |
| … | … | @@ -79,6 +79,55 @@ test('repoInfo repoints dangling HEAD to first available branch (U40)', async (t |
| 79 | 79 | t.is(readme, '# u40\n', 'git show HEAD:README.md works after repoint'); |
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | test('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 | ||
| 82 | 131 | // ── Topic Derivation ──────────────────────────────────────── |
| 83 | 132 | |
| 84 | 133 | test('deriveTopic is deterministic', async (t) => { |