From 78d831e50fe89b625535594b4caa3fc89cd0389f Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 15:43:54 +0900 Subject: [PATCH 1/4] perf(react): optimize git tree materialization for diff analysis Replace per-file git cat-file with git archive|tar pipeline, add CoW clone strategy for the second snapshot, and use async cleanup via detached rm processes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/diff.ts | 178 ++++++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 47 deletions(-) diff --git a/src/analyzers/react/diff.ts b/src/analyzers/react/diff.ts index 623281a..226a23c 100644 --- a/src/analyzers/react/diff.ts +++ b/src/analyzers/react/diff.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, execSync, spawn } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -97,11 +97,7 @@ export function analyzeReactUsageDiff( includeBuiltins: options.includeBuiltins, analyzeOptions: toAnalyzeOptions(options), } - const beforeGraph = loadGitTreeGraph(context, comparison.beforeTree) - const afterGraph = - comparison.afterTree === undefined - ? loadWorkingTreeGraph(context) - : loadGitTreeGraph(context, comparison.afterTree) + const [beforeGraph, afterGraph] = loadBothGraphs(context, comparison) if (beforeGraph === undefined && afterGraph === undefined) { throw new Error( @@ -154,22 +150,41 @@ function toAnalyzeOptions(options: ReactCliOptions): AnalyzeOptions { } } -function loadWorkingTreeGraph( - context: ReactDiffAnalysisContext, -): ComparableReactGraph | undefined { - return tryAnalyzeComparableReactGraph(context, context.repositoryRoot) -} -function loadGitTreeGraph( +function loadBothGraphs( context: ReactDiffAnalysisContext, - tree: string, -): ComparableReactGraph | undefined { - const snapshotRoot = materializeGitTreeSnapshot(context.repositoryRoot, tree) + comparison: GitDiffComparison, +): [ComparableReactGraph | undefined, ComparableReactGraph | undefined] { + const snapshotsToCleanup: string[] = [] try { - return tryAnalyzeComparableReactGraph(context, snapshotRoot) + let beforeRoot: string + let afterRoot: string + + if (comparison.afterTree === undefined) { + beforeRoot = materializeGitTreeSnapshot( + context.repositoryRoot, + comparison.beforeTree, + ) + snapshotsToCleanup.push(beforeRoot) + afterRoot = context.repositoryRoot + } else { + ;[beforeRoot, afterRoot] = materializeTwoGitTreeSnapshots( + context.repositoryRoot, + comparison.beforeTree, + comparison.afterTree, + ) + snapshotsToCleanup.push(beforeRoot, afterRoot) + } + + const beforeGraph = tryAnalyzeComparableReactGraph(context, beforeRoot) + const afterGraph = tryAnalyzeComparableReactGraph(context, afterRoot) + + return [beforeGraph, afterGraph] } finally { - fs.rmSync(snapshotRoot, { recursive: true, force: true }) + for (const dir of snapshotsToCleanup) { + spawn('rm', ['-rf', dir], { stdio: 'ignore', detached: true }).unref() + } } } @@ -989,46 +1004,115 @@ function materializeGitTreeSnapshot( const snapshotRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'foresthouse-react-diff-'), ) - const trackedFiles = runGit(repositoryRoot, [ - 'ls-tree', - '-r', - '-z', - '--name-only', - tree, - ]) - .split('\u0000') - .filter((filePath) => filePath.length > 0) - trackedFiles.forEach((filePath) => { - ensureSnapshotDirectory(snapshotRoot, filePath) - }) + execSync( + `git -C ${JSON.stringify(repositoryRoot)} archive --format=tar ${tree} | tar -x -C ${JSON.stringify(snapshotRoot)}`, + { + maxBuffer: GIT_EXEC_MAX_BUFFER, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + return snapshotRoot +} - trackedFiles.forEach((filePath) => { - const fileContent = runGit( +function materializeTwoGitTreeSnapshots( + repositoryRoot: string, + beforeTree: string, + afterTree: string, +): [string, string] { + // 1. Materialize the after tree fully + const afterSnap = materializeGitTreeSnapshot(repositoryRoot, afterTree) + + // 2. Try CoW clone + diff overlay for the before tree + try { + const beforeSnap = cloneSnapshotWithOverlay( repositoryRoot, - ['cat-file', '-p', `${tree}:${filePath}`], - { - trim: false, - }, + afterSnap, + beforeTree, + afterTree, ) - const absolutePath = path.join(snapshotRoot, ...filePath.split('/')) + return [beforeSnap, afterSnap] + } catch { + // CoW not supported — fall back to full materialization + const beforeSnap = materializeGitTreeSnapshot(repositoryRoot, beforeTree) + return [beforeSnap, afterSnap] + } +} - fs.writeFileSync(absolutePath, fileContent) - }) +function cloneSnapshotWithOverlay( + repositoryRoot: string, + sourceSnap: string, + targetTree: string, + sourceTree: string, +): string { + const targetSnap = fs.mkdtempSync( + path.join(os.tmpdir(), 'foresthouse-react-diff-'), + ) + fs.rmSync(targetSnap, { recursive: true }) + + // CoW clone (APFS on macOS, reflink on Btrfs/XFS) + if (process.platform === 'darwin') { + execFileSync('cp', ['-cR', sourceSnap, targetSnap], { stdio: 'ignore' }) + } else { + execFileSync('cp', ['-a', '--reflink=auto', sourceSnap, targetSnap], { + stdio: 'ignore', + }) + } - return snapshotRoot -} + // Find changed files between the two trees + const diffOutput = execFileSync( + 'git', + [ + '-C', + repositoryRoot, + 'diff', + '--name-status', + '--no-renames', + '-z', + targetTree, + sourceTree, + ], + { maxBuffer: GIT_EXEC_MAX_BUFFER, encoding: 'utf8' }, + ) -function ensureSnapshotDirectory(snapshotRoot: string, filePath: string): void { - const parentDirectory = path.dirname(filePath) + if (diffOutput.length === 0) { + return targetSnap + } - if (parentDirectory === '.') { - return + // Parse null-separated output: status\0path\0status\0path\0... + const parts = diffOutput.split('\0') + const filesToDelete: string[] = [] + const filesToRestore: string[] = [] + + for (let i = 0; i + 1 < parts.length; i += 2) { + const status = parts[i] + const file = parts[i + 1] + + if (status === 'A') { + // Added in source (after) — doesn't exist in target (before) + filesToDelete.push(file) + } else if (status === 'D' || status === 'M') { + // Deleted or modified in source — restore target (before) version + filesToRestore.push(file) + } } - fs.mkdirSync(path.join(snapshotRoot, ...parentDirectory.split('/')), { - recursive: true, - }) + for (const file of filesToDelete) { + fs.rmSync(path.join(targetSnap, file), { force: true }) + } + + if (filesToRestore.length > 0) { + // Extract only the changed files from the before tree + const archive = execFileSync( + 'git', + ['-C', repositoryRoot, 'archive', '--format=tar', targetTree, '--', ...filesToRestore], + { maxBuffer: GIT_EXEC_MAX_BUFFER }, + ) + execFileSync('tar', ['-x', '-C', targetSnap], { input: archive }) + } + + return targetSnap } function runGit( From 7bdaa2da94b442af7d1184625c2ea6e47736168f Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 15:46:43 +0900 Subject: [PATCH 2/4] style(react): fix biome formatting issues Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/diff.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/analyzers/react/diff.ts b/src/analyzers/react/diff.ts index 226a23c..f8fda7c 100644 --- a/src/analyzers/react/diff.ts +++ b/src/analyzers/react/diff.ts @@ -150,7 +150,6 @@ function toAnalyzeOptions(options: ReactCliOptions): AnalyzeOptions { } } - function loadBothGraphs( context: ReactDiffAnalysisContext, comparison: GitDiffComparison, @@ -1106,7 +1105,15 @@ function cloneSnapshotWithOverlay( // Extract only the changed files from the before tree const archive = execFileSync( 'git', - ['-C', repositoryRoot, 'archive', '--format=tar', targetTree, '--', ...filesToRestore], + [ + '-C', + repositoryRoot, + 'archive', + '--format=tar', + targetTree, + '--', + ...filesToRestore, + ], { maxBuffer: GIT_EXEC_MAX_BUFFER }, ) execFileSync('tar', ['-x', '-C', targetSnap], { input: archive }) From 538b9ee6f1f33eb97bc514ea31bf7e5288847543 Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 15:48:11 +0900 Subject: [PATCH 3/4] fix(react): add undefined guard for diff output parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/diff.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/analyzers/react/diff.ts b/src/analyzers/react/diff.ts index f8fda7c..af92809 100644 --- a/src/analyzers/react/diff.ts +++ b/src/analyzers/react/diff.ts @@ -1087,6 +1087,7 @@ function cloneSnapshotWithOverlay( for (let i = 0; i + 1 < parts.length; i += 2) { const status = parts[i] const file = parts[i + 1] + if (file === undefined) break if (status === 'A') { // Added in source (after) — doesn't exist in target (before) From f1b39a7d05055ccd80d9bd043820a2fcfd5e517a Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 15:50:42 +0900 Subject: [PATCH 4/4] fix(react): use fs.rm for async snapshot cleanup instead of spawn Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/diff.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/analyzers/react/diff.ts b/src/analyzers/react/diff.ts index af92809..7cc8850 100644 --- a/src/analyzers/react/diff.ts +++ b/src/analyzers/react/diff.ts @@ -1,4 +1,4 @@ -import { execFileSync, execSync, spawn } from 'node:child_process' +import { execFileSync, execSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -182,7 +182,7 @@ function loadBothGraphs( return [beforeGraph, afterGraph] } finally { for (const dir of snapshotsToCleanup) { - spawn('rm', ['-rf', dir], { stdio: 'ignore', detached: true }).unref() + fs.rm(dir, { recursive: true, force: true }, () => {}) } } }