const fs = require('fs');
const path = require('path');
/**
* Recursive byte-size of a directory tree. Synchronous (matches the
* rest of repos.js which is sync). Best-effort — silently skips any
* file/dir it can't read so a single permission/race issue doesn't
* break the whole scan.
*
* For bare repos this counts loose objects, packs, idx/bitmap/rev,
* refs/, packed-refs, hooks/, info/ — the actual on-disk footprint.
*/
function dirSize (p) {
let total = 0;
let entries;
try {
entries = fs.readdirSync(p, { withFileTypes: true });
} catch {
return 0;
}
for (const entry of entries) {
const full = path.join(p, entry.name);
if (entry.isDirectory()) {
total += dirSize(full);
} else if (entry.isFile()) {
try { total += fs.statSync(full).size; } catch {}
}
}
return total;
}
/**
* Scan a directory for bare git repos.
* Returns a Map of repo name → info.
*
* `opts.previous` is the Map from the last scan. Repos whose footprint key
* is unchanged carry their packSize forward instead of re-walking the tree,
* which is ~89% of the scan's cost and blocks the event loop while it runs.
* Omit it for a full, unconditional scan — the state stays with the caller,
* so this function has no hidden memo of its own and repeat calls without
* `previous` behave exactly as before.
*/
function scanRepos (dir, opts = {}) {
const previous = opts.previous instanceof Map ? opts.previous : null;
const repos = new Map();
let entries;
try {
entries = fs.readdirSync(dir);
} catch {
return repos;
}
for (const entry of entries) {
if (!entry.endsWith('.git')) continue;
const repoPath = path.join(dir, entry);
const name = entry.slice(0, -4);
try {
const stat = fs.statSync(repoPath);
if (!stat.isDirectory()) continue;
} catch {
continue;
}
const info = repoInfo(repoPath, name, previous ? previous.get(name) : null);
if (info) repos.set(name, info);
}
return repos;
}
/**
* Newest mtime anywhere under a repo's refs/ tree, or null if unreadable.
*
* Recursive: branches nest (refs/heads/feature/x) and tags sit alongside
* them, and a write to either is real repo activity. Directory mtimes count
* too — creating or deleting a ref restamps the containing directory even
* when no surviving file is newer, so a branch deletion still registers.
*
* Best-effort and synchronous, matching the rest of this file: anything
* unreadable is skipped rather than failing the scan.
*/
function newestRefMtime (dir) {
let newest = null;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return null;
}
try {
const st = fs.statSync(dir);
newest = st.mtimeMs;
} catch {}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const sub = newestRefMtime(full);
if (sub !== null && (newest === null || sub > newest)) newest = sub;
} else if (entry.isFile()) {
try {
const st = fs.statSync(full);
if (newest === null || st.mtimeMs > newest) newest = st.mtimeMs;
} catch {}
}
}
return newest;
}
/**
* Get info for a single bare repo.
*
* U40 fix (audit doc 2026-04-14-ux-reaudit.md): if HEAD points to a
* branch that doesn't exist (`git init --bare` leaves HEAD →
* refs/heads/master, but the operator's first push was `main` so
* master never gets created), `git show HEAD:path` and friends throw
* "fatal: invalid object name 'HEAD'". Detect that here and repoint
* HEAD to the first available branch — the SAME branch repoInfo
* picks for the .head field, so downstream readers stay consistent.
*/
function repoInfo (repoPath, name, cached = null) {
const refs = readRefs(repoPath);
const branches = Object.keys(refs);
const headBranch = refs.master ? 'master'
: refs.main ? 'main'
: (branches[0] || null);
const head = headBranch ? refs[headBranch] : null;
if (headBranch) repointHeadIfDangling(repoPath, refs, headBranch);
// Computed once and used twice below: as the newest-activity signal, and
// as part of the footprint key.
const refsMtime = newestRefMtime(path.join(repoPath, 'refs'));
// Cheap fingerprint of the repo's on-disk footprint. Three stats stand in
// for a full tree walk: the newest ref write (a push), objects/ (new
// loose-object subdirs), and objects/pack/ (a repack or gc). Together
// those cover every way a repo normally gains or loses bytes.
let footprint = refsMtime || 0;
for (const rel of [['objects'], ['objects', 'pack']]) {
try {
const st = fs.statSync(path.join(repoPath, ...rel));
if (st.mtimeMs > footprint) footprint = st.mtimeMs;
} catch {}
}
// Real bare-repo footprint: walk the whole dir. Counts loose objects
// under objects/xx/, packs + idx + bitmap + rev, refs/, packed-refs,
// info/, hooks/, etc. Previously only `objects/pack/*.pack` bytes were
// counted — that misses freshly-fetched repos where content is still
// loose, and produces 0 for many real repos (which then renders blank
// on /repos because of `truthy ?` template check).
//
// That walk is ~89% of a scan (364ms of 409ms across 147 live repos,
// 7.9GB), and scanRepos is synchronous — so it stalls the daemon's event
// loop, and the repos-dir watcher re-runs it on every push. An untouched
// repo cannot have changed size, so carry the previous value forward when
// the footprint key matches.
//
// Deliberately not exhaustive: a gc that only prunes loose objects inside
// an existing objects/xx/ subdir, touching no ref and no pack, does not
// move the key, leaving a slightly stale byte count until the repo next
// changes. packSize is display-only, so that is the right trade against
// stalling the event loop on every scan.
const size = (cached && cached.footprint === footprint && typeof cached.packSize === 'number')
? cached.packSize
: dirSize(repoPath);
// Last-activity signal, as the newest of three independent sources. Each
// is probed separately so a missing one cannot suppress the others — the
// previous version nested packed-refs inside HEAD's try, so a repo with no
// readable HEAD reported null even when its refs had just been written.
//
// Loose refs are the load-bearing case. A bare repo that has never been
// repacked keeps every branch as a file under refs/, and a push rewrites
// that file WITHOUT touching HEAD or packed-refs. Reading only those two
// made lastUpdated report ~repo-creation time for exactly those repos —
// the majority on a real forge (92 of 147 on the live backend had no
// packed-refs at all, and 69 of 99 checkable repos were over an hour stale
// against their own newest ref), which makes it useless for ordering by
// recency. See test 'repoInfo.lastUpdated tracks loose refs'.
let lastUpdated = null;
const bump = (ms) => {
if (typeof ms === 'number' && (lastUpdated === null || ms > lastUpdated)) lastUpdated = ms;
};
try { bump(fs.statSync(path.join(repoPath, 'HEAD')).mtimeMs); } catch {}
try { bump(fs.statSync(path.join(repoPath, 'packed-refs')).mtimeMs); } catch {}
bump(refsMtime);
return {
name,
path: repoPath,
branches,
branchCount: branches.length,
head,
packSize: size,
lastUpdated,
footprint
};
}
/**
* If the bare repo's HEAD is a symbolic ref pointing at a branch that
* doesn't exist (the dangling-HEAD case described above), rewrite
* HEAD to point at `targetBranch` instead. No-op when HEAD already
* resolves cleanly.
*
* Direct file write — no `git symbolic-ref` subprocess. The HEAD
* file format is: `ref: refs/heads/<branch>\n` for symbolic refs,
* or a plain SHA for detached HEAD.
*/
function repointHeadIfDangling (repoPath, refs, targetBranch) {
if (!targetBranch || refs[targetBranch] == null) return false;
const headPath = path.join(repoPath, 'HEAD');
let raw;
try { raw = fs.readFileSync(headPath, 'utf-8').trim(); } catch { return false; }
// Detached HEAD (just a SHA) — leave alone.
if (!raw.startsWith('ref:')) return false;
const currentTarget = raw.slice('ref:'.length).trim();
if (!currentTarget.startsWith('refs/heads/')) return false;
const currentBranch = currentTarget.slice('refs/heads/'.length);
// HEAD already points at a branch that exists — nothing to do.
if (refs[currentBranch] != null) return false;
if (currentBranch === targetBranch) return false;
try {
fs.writeFileSync(headPath, `ref: refs/heads/${targetBranch}\n`);
return true;
} catch {
return false;
}
}
/**
* Read refs from a bare repo — direct file read, no subprocess.
* Returns { refShortName: sha }
*/
function readRefs (repoPath) {
const refs = {};
// Read packed-refs (most repos have this)
try {
const packed = fs.readFileSync(path.join(repoPath, 'packed-refs'), 'utf-8');
for (const line of packed.split('\n')) {
if (line.startsWith('#') || line.startsWith('^')) continue;
const parts = line.trim().split(' ');
if (parts.length < 2) continue;
const sha = parts[0];
const ref = parts[1];
if (ref.startsWith('refs/heads/')) {
refs[ref.slice('refs/heads/'.length)] = sha;
}
}
} catch {}
// Read loose refs (override packed)
try {
const headsDir = path.join(repoPath, 'refs', 'heads');
const entries = fs.readdirSync(headsDir);
for (const entry of entries) {
try {
const sha = fs.readFileSync(path.join(headsDir, entry), 'utf-8').trim();
refs[entry] = sha;
} catch {}
}
} catch {}
return refs;
}
module.exports = { scanRepos, repoInfo, readRefs };