Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 140 additions & 48 deletions src/analyzers/react/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFileSync } 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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -154,22 +150,40 @@ function toAnalyzeOptions(options: ReactCliOptions): AnalyzeOptions {
}
}

function loadWorkingTreeGraph(
function loadBothGraphs(
context: ReactDiffAnalysisContext,
): ComparableReactGraph | undefined {
return tryAnalyzeComparableReactGraph(context, context.repositoryRoot)
}

function loadGitTreeGraph(
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) {
fs.rm(dir, { recursive: true, force: true }, () => {})
}
}
}

Expand Down Expand Up @@ -989,46 +1003,124 @@ 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'],
},
)

trackedFiles.forEach((filePath) => {
const fileContent = runGit(
return snapshotRoot
}

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 (file === undefined) break

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(
Expand Down
Loading