OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync / lib / repos.js

273 lines · javascript ref:
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 };