import fs from 'node:fs/promises'; import path from 'node:path'; import { execFile } from 'node:child_process'; interface MtimeSnap { root: number; gitHead: number | null; gitIndex: number | null; } interface CacheEntry { files: string[]; mtimes: MtimeSnap; } const cache = new Map(); // keyed by projectId // Concurrent calls with a cold/stale cache may both spawn rg. The result is // deterministic so they overwrite identically — no data corruption, just a // rare extra subprocess. Acceptable for single-user mode. export async function getProjectFiles(projectId: string, projectRoot: string): Promise { const current = await snapMtimes(projectRoot); const cached = cache.get(projectId); if (cached && eqMtimes(cached.mtimes, current)) { return cached.files; } const files = await runRgFiles(projectRoot); cache.set(projectId, { files, mtimes: current }); return files; } async function snapMtimes(root: string): Promise { const rootStat = await fs.stat(root); let gitHead: number | null = null; let gitIndex: number | null = null; // best-effort; ignore failure because the project may not be a git repo try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {} try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {} return { root: rootStat.mtimeMs, gitHead, gitIndex }; } function eqMtimes(a: MtimeSnap, b: MtimeSnap): boolean { return a.root === b.root && a.gitHead === b.gitHead && a.gitIndex === b.gitIndex; } function runRgFiles(root: string): Promise { return new Promise((resolve, reject) => { execFile('rg', ['--files'], { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => { if (err) return reject(err); resolve(stdout.split('\n').filter(Boolean)); }); }); }