Skip to content

Commit 21d22bb

Browse files
author
IM.codes
committed
perf(file-browser): cache git status and diff lookups
1 parent b77da26 commit 21d22bb

File tree

4 files changed

+561
-74
lines changed

4 files changed

+561
-74
lines changed

src/daemon/command-handler.ts

Lines changed: 203 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,203 @@ async function handleFsRead(cmd: Record<string, unknown>, serverLink: ServerLink
27122712
}
27132713
}
27142714

2715+
const GIT_STATUS_CACHE_TTL_MS = 5_000;
2716+
const GIT_DIFF_CACHE_TTL_MS = 5_000;
2717+
2718+
type GitStatusFile = { path: string; code: string; additions?: number; deletions?: number };
2719+
2720+
interface RepoContext {
2721+
repoRoot: string;
2722+
gitDir: string;
2723+
repoSignature: string;
2724+
}
2725+
2726+
interface GitStatusSnapshot {
2727+
repoRoot: string;
2728+
repoSignature: string;
2729+
files: GitStatusFile[];
2730+
}
2731+
2732+
interface GitDiffSnapshot {
2733+
repoRoot: string;
2734+
repoSignature: string;
2735+
fileSignature: string;
2736+
diff: string;
2737+
}
2738+
2739+
const gitStatusCache = new Map<string, { expiresAt: number; value: GitStatusSnapshot }>();
2740+
const gitStatusInflight = new Map<string, Promise<GitStatusSnapshot>>();
2741+
const gitDiffCache = new Map<string, { expiresAt: number; value: GitDiffSnapshot }>();
2742+
const gitDiffInflight = new Map<string, Promise<GitDiffSnapshot>>();
2743+
2744+
function normalizeFsPath(value: string): string {
2745+
return nodePath.resolve(value);
2746+
}
2747+
2748+
function isPathInside(root: string, candidate: string): boolean {
2749+
const normalizedRoot = normalizeFsPath(root);
2750+
const normalizedCandidate = normalizeFsPath(candidate);
2751+
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + nodePath.sep);
2752+
}
2753+
2754+
async function safeStatSignature(targetPath: string): Promise<string> {
2755+
try {
2756+
const stats = await fsStat(targetPath);
2757+
return `${stats.mtimeMs}:${stats.size}`;
2758+
} catch {
2759+
return 'missing';
2760+
}
2761+
}
2762+
2763+
async function resolveGitDir(dotGitPath: string, repoRoot: string): Promise<string | null> {
2764+
try {
2765+
const stats = await fsStat(dotGitPath);
2766+
if (stats.isDirectory()) return dotGitPath;
2767+
if (!stats.isFile()) return null;
2768+
const raw = await fsReadFileRaw(dotGitPath, 'utf8');
2769+
const match = raw.match(/^gitdir:\s*(.+)\s*$/mi);
2770+
if (!match?.[1]) return null;
2771+
return nodePath.resolve(repoRoot, match[1].trim());
2772+
} catch {
2773+
return null;
2774+
}
2775+
}
2776+
2777+
async function findRepoRoot(startPath: string): Promise<{ repoRoot: string; gitDir: string } | null> {
2778+
let current = normalizeFsPath(startPath);
2779+
while (true) {
2780+
const dotGit = nodePath.join(current, '.git');
2781+
const gitDir = await resolveGitDir(dotGit, current);
2782+
if (gitDir) return { repoRoot: current, gitDir };
2783+
const parent = nodePath.dirname(current);
2784+
if (parent === current) break;
2785+
current = parent;
2786+
}
2787+
return null;
2788+
}
2789+
2790+
async function buildRepoSignature(gitDir: string): Promise<string> {
2791+
const indexSig = await safeStatSignature(nodePath.join(gitDir, 'index'));
2792+
const headPath = nodePath.join(gitDir, 'HEAD');
2793+
const headSig = await safeStatSignature(headPath);
2794+
let refSig = 'none';
2795+
try {
2796+
const headRaw = await fsReadFileRaw(headPath, 'utf8');
2797+
const match = headRaw.match(/^ref:\s*(.+)\s*$/m);
2798+
if (match?.[1]) {
2799+
refSig = await safeStatSignature(nodePath.join(gitDir, match[1].trim()));
2800+
}
2801+
} catch {
2802+
refSig = 'missing';
2803+
}
2804+
return `${indexSig}|${headSig}|${refSig}`;
2805+
}
2806+
2807+
async function resolveRepoContext(startPath: string): Promise<RepoContext | null> {
2808+
const repo = await findRepoRoot(startPath);
2809+
if (!repo) return null;
2810+
return {
2811+
repoRoot: repo.repoRoot,
2812+
gitDir: repo.gitDir,
2813+
repoSignature: await buildRepoSignature(repo.gitDir),
2814+
};
2815+
}
2816+
2817+
async function loadRepoGitStatusSnapshot(repoRoot: string, repoSignature: string): Promise<GitStatusSnapshot> {
2818+
const { stdout } = await execAsync('git status --porcelain -u', { cwd: repoRoot, timeout: 5000 });
2819+
const files: GitStatusFile[] = [];
2820+
for (const line of stdout.split('\n')) {
2821+
if (!line.trim()) continue;
2822+
const code = line.slice(0, 2).trim();
2823+
const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
2824+
files.push({ path: nodePath.join(repoRoot, filePath), code });
2825+
}
2826+
return { repoRoot, repoSignature, files };
2827+
}
2828+
2829+
async function getRepoGitStatusSnapshot(startPath: string): Promise<GitStatusSnapshot | null> {
2830+
const context = await resolveRepoContext(startPath);
2831+
if (!context) return null;
2832+
const cached = gitStatusCache.get(context.repoRoot);
2833+
if (cached && cached.expiresAt > Date.now() && cached.value.repoSignature === context.repoSignature) {
2834+
return cached.value;
2835+
}
2836+
const inflight = gitStatusInflight.get(context.repoRoot);
2837+
if (inflight) return await inflight;
2838+
const promise = loadRepoGitStatusSnapshot(context.repoRoot, context.repoSignature)
2839+
.then((value) => {
2840+
gitStatusCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS });
2841+
return value;
2842+
})
2843+
.finally(() => {
2844+
gitStatusInflight.delete(context.repoRoot);
2845+
});
2846+
gitStatusInflight.set(context.repoRoot, promise);
2847+
return await promise;
2848+
}
2849+
2850+
async function loadFileGitDiffSnapshot(realPath: string, repoRoot: string, repoSignature: string, fileSignature: string): Promise<GitDiffSnapshot> {
2851+
let diff = '';
2852+
try {
2853+
const { stdout } = await execAsync(`git diff HEAD -- ${JSON.stringify(realPath)}`, { cwd: repoRoot, timeout: 5000 });
2854+
diff = stdout;
2855+
} catch { /* ignore */ }
2856+
if (!diff) {
2857+
try {
2858+
const { stdout } = await execAsync(`git diff -- ${JSON.stringify(realPath)}`, { cwd: repoRoot, timeout: 5000 });
2859+
diff = stdout;
2860+
} catch { /* ignore */ }
2861+
}
2862+
return { repoRoot, repoSignature, fileSignature, diff };
2863+
}
2864+
2865+
async function getFileGitDiffSnapshot(realPath: string): Promise<GitDiffSnapshot | null> {
2866+
const context = await resolveRepoContext(nodePath.dirname(realPath));
2867+
if (!context) return null;
2868+
const fileSignature = await safeStatSignature(realPath);
2869+
const cached = gitDiffCache.get(realPath);
2870+
if (
2871+
cached
2872+
&& cached.expiresAt > Date.now()
2873+
&& cached.value.repoSignature === context.repoSignature
2874+
&& cached.value.fileSignature === fileSignature
2875+
) {
2876+
return cached.value;
2877+
}
2878+
const inflight = gitDiffInflight.get(realPath);
2879+
if (inflight) return await inflight;
2880+
const promise = loadFileGitDiffSnapshot(realPath, context.repoRoot, context.repoSignature, fileSignature)
2881+
.then((value) => {
2882+
gitDiffCache.set(realPath, { value, expiresAt: Date.now() + GIT_DIFF_CACHE_TTL_MS });
2883+
return value;
2884+
})
2885+
.finally(() => {
2886+
gitDiffInflight.delete(realPath);
2887+
});
2888+
gitDiffInflight.set(realPath, promise);
2889+
return await promise;
2890+
}
2891+
2892+
function filterRepoFilesForPath(files: GitStatusFile[], requestedPath: string): GitStatusFile[] {
2893+
return files.filter((file) => isPathInside(requestedPath, file.path));
2894+
}
2895+
2896+
function invalidateGitCachesForPath(targetPath: string): void {
2897+
const normalized = normalizeFsPath(targetPath);
2898+
gitDiffCache.delete(normalized);
2899+
gitDiffInflight.delete(normalized);
2900+
for (const key of gitStatusCache.keys()) {
2901+
if (isPathInside(key, normalized)) gitStatusCache.delete(key);
2902+
}
2903+
}
2904+
2905+
export function __resetFsGitCachesForTests(): void {
2906+
gitStatusCache.clear();
2907+
gitStatusInflight.clear();
2908+
gitDiffCache.clear();
2909+
gitDiffInflight.clear();
2910+
}
2911+
27152912
/** fs.git_status — return git modified file list for a directory */
27162913
async function handleFsGitStatus(cmd: Record<string, unknown>, serverLink: ServerLink): Promise<void> {
27172914
const rawPath = cmd.path as string | undefined;
@@ -2728,32 +2925,8 @@ async function handleFsGitStatus(cmd: Record<string, unknown>, serverLink: Serve
27282925
try { serverLink.send({ type: 'fs.git_status_response', requestId, path: rawPath, status: 'error', error: 'forbidden_path' }); } catch { /* ignore */ }
27292926
return;
27302927
}
2731-
2732-
const { stdout } = await execAsync('git status --porcelain -u', { cwd: real, timeout: 5000 });
2733-
const files: Array<{ path: string; code: string; additions?: number; deletions?: number }> = [];
2734-
for (const line of stdout.split('\n')) {
2735-
if (!line.trim()) continue;
2736-
const code = line.slice(0, 2).trim();
2737-
const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1'); // unquote if needed
2738-
files.push({ path: nodePath.join(real, filePath), code });
2739-
}
2740-
// Enrich with +/- line stats from git diff --numstat (best-effort)
2741-
try {
2742-
const { stdout: numstat } = await execAsync('git diff --numstat HEAD 2>/dev/null || git diff --numstat', { cwd: real, timeout: 5000 });
2743-
const statsMap = new Map<string, { add: number; del: number }>();
2744-
for (const line of numstat.split('\n')) {
2745-
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
2746-
if (m) {
2747-
const add = m[1] === '-' ? 0 : parseInt(m[1], 10);
2748-
const del = m[2] === '-' ? 0 : parseInt(m[2], 10);
2749-
statsMap.set(nodePath.join(real, m[3].trim()), { add, del });
2750-
}
2751-
}
2752-
for (const f of files) {
2753-
const s = statsMap.get(f.path);
2754-
if (s) { f.additions = s.add; f.deletions = s.del; }
2755-
}
2756-
} catch { /* ignore — stats are best-effort */ }
2928+
const snapshot = await getRepoGitStatusSnapshot(real);
2929+
const files = snapshot ? filterRepoFilesForPath(snapshot.files, real) : [];
27572930
try { serverLink.send({ type: 'fs.git_status_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', files }); } catch { /* ignore */ }
27582931
} catch (err) {
27592932
const msg = err instanceof Error ? err.message : String(err);
@@ -2779,20 +2952,8 @@ async function handleFsGitDiff(cmd: Record<string, unknown>, serverLink: ServerL
27792952
try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, status: 'error', error: 'forbidden_path' }); } catch { /* ignore */ }
27802953
return;
27812954
}
2782-
2783-
const dir = nodePath.dirname(real);
2784-
// Try staged+unstaged diff vs HEAD; fall back to index diff; then untracked diff
2785-
let diff = '';
2786-
try {
2787-
const { stdout } = await execAsync(`git diff HEAD -- ${JSON.stringify(real)}`, { cwd: dir, timeout: 5000 });
2788-
diff = stdout;
2789-
} catch { /* ignore */ }
2790-
if (!diff) {
2791-
try {
2792-
const { stdout } = await execAsync(`git diff -- ${JSON.stringify(real)}`, { cwd: dir, timeout: 5000 });
2793-
diff = stdout;
2794-
} catch { /* ignore */ }
2795-
}
2955+
const snapshot = await getFileGitDiffSnapshot(real);
2956+
const diff = snapshot?.diff ?? '';
27962957
// Untracked files: no diff (nothing meaningful to compare against)
27972958
try { serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', diff }); } catch { /* ignore */ }
27982959
} catch (err) {
@@ -2895,6 +3056,7 @@ async function handleFsWrite(cmd: Record<string, unknown>, serverLink: ServerLin
28953056
// Write the file
28963057
await fsWriteFile(real, content, 'utf-8');
28973058
const newStats = await fsStat(real);
3059+
invalidateGitCachesForPath(real);
28983060
try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ }
28993061
} catch (err) {
29003062
try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, status: 'error', error: err instanceof Error ? err.message : String(err) }); } catch { /* ignore */ }
@@ -2913,6 +3075,7 @@ async function handleFsWrite(cmd: Record<string, unknown>, serverLink: ServerLin
29133075
await fsWriteFile(resolved, content, 'utf-8');
29143076
const newStats = await fsStat(resolved);
29153077
const real = await fsRealpath(resolved);
3078+
invalidateGitCachesForPath(real);
29163079
try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ }
29173080
} catch (err) {
29183081
const msg = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)