Created
June 15, 2025 17:25
-
-
Save ijin/8946d83e77ddcb108d62c82073550eaf to your computer and use it in GitHub Desktop.
roo-benchmark-stageAll.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env npx ts-node | |
/** | |
* TypeScript benchmark for stageAll() performance testing | |
* Replicates operations from ShadowCheckpointService.ts including: | |
* - renameNestedGitRepos() filesystem operations | |
* - Batch git operations using simple-git (matches real implementation) | |
* - Proper async/await patterns | |
* - Realistic nested git repo scanning | |
* | |
* Usage: | |
* From project root: npx ts-node scripts/benchmark-stageall.ts [REPO_PATH] | |
* External: copy to /tmp and install simple-git: npm install simple-git | |
*/ | |
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, readdirSync, renameSync } from 'fs'; | |
import { join, dirname } from 'path'; | |
import { tmpdir } from 'os'; | |
import simpleGit from 'simple-git'; | |
type SimpleGit = ReturnType<typeof simpleGit>; | |
// Colors for output | |
const colors = { | |
red: '\x1b[31m', | |
green: '\x1b[32m', | |
yellow: '\x1b[33m', | |
blue: '\x1b[34m', | |
reset: '\x1b[0m' | |
}; | |
// Configuration | |
const RUNS = 5; | |
const GIT_DISABLED_SUFFIX = '.git_disabled_roo'; | |
interface GitStatusFile { | |
path: string; | |
index: string; | |
working_dir: string; | |
} | |
interface BenchmarkResult { | |
averageTime: number; | |
runs: number[]; | |
breakdown: { | |
renameNestedGitRepos?: number; | |
gitOperations?: number; | |
total: number; | |
}; | |
} | |
class StandaloneBenchmark { | |
private repoPath: string; | |
private cachedNestedGitPaths: string[] | null = null; | |
private git: SimpleGit; | |
constructor(repoPath?: string) { | |
this.repoPath = repoPath || process.cwd(); | |
if (!existsSync(this.repoPath)) { | |
throw new Error(`Directory '${this.repoPath}' does not exist`); | |
} | |
if (!existsSync(join(this.repoPath, '.git'))) { | |
throw new Error(`'${this.repoPath}' is not a git repository`); | |
} | |
this.git = simpleGit(this.repoPath); | |
} | |
private log(message: string, color?: keyof typeof colors) { | |
const colorCode = color ? colors[color] : ''; | |
const resetCode = color ? colors.reset : ''; | |
console.log(`${colorCode}${message}${resetCode}`); | |
} | |
private section(title: string) { | |
this.log(`\n=== ${title} ===`, 'yellow'); | |
} | |
private async createLargeChangeScenario(): Promise<void> { | |
this.section('Creating Large Change Scenario'); | |
// Create a few nested git repositories to test the renaming overhead | |
const createNestedGitRepo = (path: string) => { | |
if (!existsSync(path)) { | |
mkdirSync(path, { recursive: true }); | |
const gitDir = join(path, '.git'); | |
mkdirSync(gitDir); | |
writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n'); | |
writeFileSync(join(gitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n'); | |
} | |
}; | |
// Create nested git repos to make the benchmark realistic | |
createNestedGitRepo(join(this.repoPath, 'benchmark-temp', 'nested-repo-1')); | |
createNestedGitRepo(join(this.repoPath, 'benchmark-temp', 'subdir', 'nested-repo-2')); | |
this.log('Created 2 nested git repositories'); | |
// Find TypeScript files to modify | |
const lsFilesResult = await this.git.raw(['ls-files', '*.ts', 'src/**/*.ts']); | |
const tsFiles = lsFilesResult.split('\n').filter((f: string) => f.length > 0).slice(0, 80); | |
this.log(`Found ${tsFiles.length} TypeScript files to modify`); | |
// Modify TypeScript files (add comments) | |
for (let i = 0; i < Math.min(60, tsFiles.length); i++) { | |
const filePath = join(this.repoPath, tsFiles[i]); | |
if (existsSync(filePath)) { | |
const content = readFileSync(filePath, 'utf8'); | |
writeFileSync(filePath, content + `\n// Benchmark modification - ${new Date().toISOString()}\n`); | |
} | |
} | |
this.log('Modified 60 TypeScript files'); | |
// Create new files | |
const benchmarkTempDir = join(this.repoPath, 'benchmark-temp'); | |
if (!existsSync(benchmarkTempDir)) { | |
mkdirSync(benchmarkTempDir, { recursive: true }); | |
} | |
for (let i = 1; i <= 12; i++) { | |
const content = `// Benchmark test file ${i} | |
// Created: ${new Date().toISOString()} | |
export const testFunction${i} = () => { | |
console.log('Benchmark test function ${i}'); | |
};`; | |
writeFileSync(join(benchmarkTempDir, `test-file-${i}.ts`), content); | |
} | |
this.log('Created 12 new files'); | |
// Create files to delete (then delete them) - but skip the git operations for now to avoid path issues | |
const deleteFiles = []; | |
for (let i = 1; i <= 7; i++) { | |
const filePath = join(benchmarkTempDir, `delete-me-${i}.txt`); | |
writeFileSync(filePath, `// Temporary file ${i}`); | |
deleteFiles.push(`benchmark-temp/delete-me-${i}.txt`); | |
} | |
try { | |
await this.git.add(deleteFiles); | |
await this.git.commit('Add files to delete for benchmark'); | |
this.log('Successfully added and committed delete-me files'); | |
} catch (error) { | |
this.log(`Warning: Could not add delete-me files: ${error}`, 'yellow'); | |
// Continue without the delete files scenario | |
} | |
// Delete the files | |
for (let i = 1; i <= 7; i++) { | |
const filePath = join(benchmarkTempDir, `delete-me-${i}.txt`); | |
if (existsSync(filePath)) { | |
rmSync(filePath); | |
} | |
} | |
this.log('Created and deleted 7 files'); | |
// Modify package.json if it exists | |
const packageJsonPath = join(this.repoPath, 'package.json'); | |
if (existsSync(packageJsonPath)) { | |
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); | |
packageJson.description = (packageJson.description || '') + ' - benchmark modified'; | |
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); | |
this.log('Modified package.json'); | |
} | |
this.log('Large change scenario created successfully', 'green'); | |
} | |
private async getRepositoryStats(): Promise<void> { | |
this.section('Repository Statistics'); | |
const lsFilesResult = await this.git.raw(['ls-files']); | |
const totalFiles = lsFilesResult.split('\n').filter((line: string) => line.trim()).length; | |
const status = await this.git.status(); | |
const modifiedFiles = status.files.length; | |
const diffCachedResult = await this.git.diff(['--cached', '--name-only']); | |
const stagedFiles = diffCachedResult.split('\n').filter((line: string) => line.trim()).length; | |
this.log(`Total tracked files: ${totalFiles}`); | |
this.log(`Modified files: ${modifiedFiles}`); | |
this.log(`Staged files: ${stagedFiles}`); | |
} | |
// Replication of findAndCacheNestedGitRepoPaths from ShadowCheckpointService.ts | |
private async findAndCacheNestedGitRepoPaths(): Promise<string[]> { | |
if (this.cachedNestedGitPaths === null) { | |
// Simulate ripgrep scanning for nested .git/HEAD files | |
// This is the expensive operation that the original benchmark missed | |
const startTime = process.hrtime.bigint(); | |
this.cachedNestedGitPaths = []; | |
// Recursively scan for nested .git directories (simulating ripgrep) | |
const scanDirectory = (dir: string, relativePath: string = ''): void => { | |
try { | |
const entries = readdirSync(dir, { withFileTypes: true }); | |
for (const entry of entries) { | |
if (entry.name === '.git' && entry.isDirectory()) { | |
const gitHeadPath = join(dir, entry.name, 'HEAD'); | |
if (existsSync(gitHeadPath) && relativePath !== '') { | |
// Found nested .git directory | |
this.cachedNestedGitPaths!.push(join(relativePath, entry.name)); | |
} | |
} else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { | |
// Recursively scan subdirectories (limited depth to avoid infinite loops) | |
const newRelativePath = relativePath ? join(relativePath, entry.name) : entry.name; | |
if (newRelativePath.split('/').length < 4) { // Max depth 3 | |
scanDirectory(join(dir, entry.name), newRelativePath); | |
} | |
} | |
} | |
} catch (error) { | |
// Ignore permission errors, etc. | |
} | |
}; | |
scanDirectory(this.repoPath); | |
const endTime = process.hrtime.bigint(); | |
const scanTime = Number(endTime - startTime) / 1000000; | |
this.log(`Found ${this.cachedNestedGitPaths.length} nested .git directories in ${scanTime.toFixed(1)}ms`); | |
} | |
return this.cachedNestedGitPaths; | |
} | |
// Replication of renameNestedGitRepos from ShadowCheckpointService.ts | |
private async renameNestedGitRepos(disable: boolean): Promise<number> { | |
const startTime = process.hrtime.bigint(); | |
const nestedGitDirPaths = await this.findAndCacheNestedGitRepoPaths(); | |
for (const relativeGitDirPath of nestedGitDirPaths) { | |
const originalGitPath = join(this.repoPath, relativeGitDirPath); | |
const disabledGitPath = originalGitPath + GIT_DISABLED_SUFFIX; | |
try { | |
if (disable) { | |
if (existsSync(originalGitPath) && !originalGitPath.endsWith(GIT_DISABLED_SUFFIX)) { | |
renameSync(originalGitPath, disabledGitPath); | |
} | |
} else { | |
if (existsSync(disabledGitPath)) { | |
renameSync(disabledGitPath, originalGitPath); | |
} | |
} | |
} catch (error) { | |
// Log error but continue (matching original behavior) | |
} | |
} | |
const endTime = process.hrtime.bigint(); | |
return Number(endTime - startTime) / 1000000; // Return time in milliseconds | |
} | |
private async parseGitStatus(): Promise<GitStatusFile[]> { | |
const status = await this.git.status(['--porcelain=v1', '-uall']); | |
const files: GitStatusFile[] = []; | |
if (status.files && status.files.length > 0) { | |
status.files.forEach((file: any) => { | |
files.push({ | |
path: file.path, | |
index: file.index || ' ', | |
working_dir: file.working_dir || ' ' | |
}); | |
}); | |
} | |
return files; | |
} | |
private processStatusFiles(files: GitStatusFile[]): { filesToAdd: string[], filesToRemove: string[] } { | |
const filesToAdd: string[] = []; | |
const filesToRemove: string[] = []; | |
// Logic from ShadowCheckpointService.ts lines 229-252 | |
files.forEach((file) => { | |
const filePath = file.path; | |
// Determine if file needs to be added or removed | |
// 'D' in index or working_dir means deleted | |
if (file.index === 'D' || file.working_dir === 'D') { | |
filesToRemove.push(filePath); | |
} else if (file.index === '?' || file.working_dir === '?') { | |
// Untracked | |
filesToAdd.push(filePath); | |
} else if (file.index === 'A' || file.working_dir === 'A') { | |
// Added | |
filesToAdd.push(filePath); | |
} else if (file.index === 'M' || file.working_dir === 'M') { | |
// Modified | |
filesToAdd.push(filePath); | |
} else if (file.index.startsWith('R') || file.working_dir.startsWith('R')) { | |
// Renamed | |
filesToAdd.push(filePath); // Add the new path | |
} else if (file.index.startsWith('C') || file.working_dir.startsWith('C')) { | |
// Copied | |
filesToAdd.push(filePath); // Add the new path | |
} | |
// Other statuses like 'U' (unmerged) might need specific handling if relevant | |
}); | |
return { filesToAdd, filesToRemove }; | |
} | |
private async runBenchmark( | |
name: string, | |
action: () => Promise<{ renameTime: number; gitTime: number; extraInfo?: string }> | |
): Promise<BenchmarkResult> { | |
this.section(`Benchmarking ${name}`); | |
const runs: number[] = []; | |
const breakdowns: any[] = []; | |
for (let run = 1; run <= RUNS; run++) { | |
await this.git.reset(); | |
const totalStartTime = process.hrtime.bigint(); | |
const result = await action(); | |
const totalEndTime = process.hrtime.bigint(); | |
const totalTime = Number(totalEndTime - totalStartTime) / 1000000; | |
runs.push(totalTime); | |
breakdowns.push({ | |
renameNestedGitRepos: result.renameTime, | |
gitOperations: result.gitTime, | |
total: totalTime | |
}); | |
const extraInfo = result.extraInfo ? ` ${result.extraInfo}` : ''; | |
this.log(`Run ${run}: ${totalTime.toFixed(1)}ms (rename: ${result.renameTime.toFixed(1)}ms, git: ${result.gitTime.toFixed(1)}ms)${extraInfo}`); | |
} | |
const averageTime = runs.reduce((sum, time) => sum + time, 0) / runs.length; | |
const avgBreakdown = { | |
renameNestedGitRepos: breakdowns.reduce((sum, b) => sum + b.renameNestedGitRepos, 0) / breakdowns.length, | |
gitOperations: breakdowns.reduce((sum, b) => sum + b.gitOperations, 0) / breakdowns.length, | |
total: averageTime | |
}; | |
this.log(`${name} average: ${averageTime.toFixed(1)}ms`, 'green'); | |
this.log(` - Rename operations: ${avgBreakdown.renameNestedGitRepos.toFixed(1)}ms`); | |
this.log(` - Git operations: ${avgBreakdown.gitOperations.toFixed(1)}ms`); | |
return { averageTime, runs, breakdown: avgBreakdown }; | |
} | |
private async benchmarkOldApproach(): Promise<BenchmarkResult> { | |
return this.runBenchmark('OLD Approach (Full stageAll with git add .)', async () => { | |
// Replication of original stageAll method | |
const renameStartTime = process.hrtime.bigint(); | |
await this.renameNestedGitRepos(true); | |
const renameTime1Ms = Number(process.hrtime.bigint() - renameStartTime) / 1000000; | |
const gitStartTime = process.hrtime.bigint(); | |
await this.git.add('.'); // Single git add . command | |
const gitTime = Number(process.hrtime.bigint() - gitStartTime) / 1000000; | |
const renameStartTime2 = process.hrtime.bigint(); | |
await this.renameNestedGitRepos(false); | |
const renameTime2Ms = Number(process.hrtime.bigint() - renameStartTime2) / 1000000; | |
return { | |
renameTime: renameTime1Ms + renameTime2Ms, | |
gitTime: gitTime | |
}; | |
}); | |
} | |
private async benchmarkNewApproach(): Promise<BenchmarkResult> { | |
return this.runBenchmark('NEW Approach (Full stageAll with selective staging)', async () => { | |
// Replication of new stageAll method | |
const renameStartTime = process.hrtime.bigint(); | |
await this.renameNestedGitRepos(true); | |
const renameTime1Ms = Number(process.hrtime.bigint() - renameStartTime) / 1000000; | |
const gitStartTime = process.hrtime.bigint(); | |
// Step 1: git status | |
const files = await this.parseGitStatus(); | |
// Step 2: Process status files | |
const { filesToAdd, filesToRemove } = this.processStatusFiles(files); | |
// Step 3: BATCH operations using simple-git (matches real implementation) | |
if (filesToRemove.length > 0) { | |
try { | |
await this.git.rm(filesToRemove); | |
} catch (rmError) { | |
// If batch rm fails, try individual files with fallback to --cached | |
for (const file of filesToRemove) { | |
try { | |
await this.git.rm([file]); | |
} catch { | |
try { | |
await this.git.rm(['--cached', file]); | |
} catch { | |
// Ignore if file can't be removed (already gone) | |
} | |
} | |
} | |
} | |
} | |
if (filesToAdd.length > 0) { | |
try { | |
await this.git.add(filesToAdd); | |
} catch (addError) { | |
// If batch add fails, try individual files | |
for (const file of filesToAdd) { | |
try { | |
await this.git.add([file]); | |
} catch { | |
// Ignore if file can't be added | |
} | |
} | |
} | |
} | |
const gitTime = Number(process.hrtime.bigint() - gitStartTime) / 1000000; | |
const renameStartTime2 = process.hrtime.bigint(); | |
await this.renameNestedGitRepos(false); | |
const renameTime2Ms = Number(process.hrtime.bigint() - renameStartTime2) / 1000000; | |
return { | |
renameTime: renameTime1Ms + renameTime2Ms, | |
gitTime: gitTime, | |
extraInfo: `[${filesToAdd.length} add, ${filesToRemove.length} rm]` | |
}; | |
}); | |
} | |
private async cleanup(): Promise<void> { | |
this.section('Cleaning Up'); | |
try { await this.git.reset(); } catch { } | |
try { await this.git.checkout(['--', '.']); } catch { } | |
try { rmSync(join(this.repoPath, 'benchmark-temp'), { recursive: true, force: true }); } catch { } | |
try { await this.git.clean('f', ['-d']); } catch { } | |
// Clean up any disabled git repos | |
if (this.cachedNestedGitPaths) { | |
for (const relativeGitDirPath of this.cachedNestedGitPaths) { | |
const disabledGitPath = join(this.repoPath, relativeGitDirPath) + GIT_DISABLED_SUFFIX; | |
if (existsSync(disabledGitPath)) { | |
try { | |
renameSync(disabledGitPath, join(this.repoPath, relativeGitDirPath)); | |
} catch { } | |
} | |
} | |
} | |
this.log('Cleanup completed', 'green'); | |
} | |
private displayResults(oldResult: BenchmarkResult, newResult: BenchmarkResult): void { | |
this.section('Performance Comparison'); | |
this.log(`OLD approach average: ${oldResult.averageTime.toFixed(1)}ms`); | |
this.log(` - Rename operations: ${oldResult.breakdown.renameNestedGitRepos?.toFixed(1)}ms`); | |
this.log(` - Git operations: ${oldResult.breakdown.gitOperations?.toFixed(1)}ms`); | |
this.log(`NEW approach average: ${newResult.averageTime.toFixed(1)}ms`); | |
this.log(` - Rename operations: ${newResult.breakdown.renameNestedGitRepos?.toFixed(1)}ms`); | |
this.log(` - Git operations: ${newResult.breakdown.gitOperations?.toFixed(1)}ms`); | |
} | |
public async run(): Promise<void> { | |
this.log('=== stageAll() Performance Benchmark ===', 'blue'); | |
this.log(`Repository: ${this.repoPath}`); | |
this.log(`Runs per test: ${RUNS}`); | |
this.log(''); | |
// Check for uncommitted changes and clean if needed | |
const status = await this.git.status(); | |
if (status.files.length > 0) { | |
this.log('Repository has uncommitted changes. Cleaning up first...', 'yellow'); | |
await this.git.reset(['--hard']); | |
await this.git.clean('f', ['-d']); | |
} | |
try { | |
// Create large change scenario | |
await this.createLargeChangeScenario(); | |
// Show repository statistics | |
await this.getRepositoryStats(); | |
// Run benchmarks | |
const oldResult = await this.benchmarkOldApproach(); | |
const newResult = await this.benchmarkNewApproach(); | |
// Display results | |
this.displayResults(oldResult, newResult); | |
} finally { | |
await this.cleanup(); | |
} | |
this.log('\nBenchmark completed successfully!', 'blue'); | |
} | |
} | |
// Main execution | |
async function main() { | |
const args = process.argv.slice(2); | |
if (args.includes('-h') || args.includes('--help')) { | |
console.log('Usage: npx ts-node benchmark-stageall.ts [REPO_PATH]'); | |
console.log(''); | |
console.log('Arguments:'); | |
console.log(' REPO_PATH Path to git repository (default: current directory)'); | |
console.log(''); | |
console.log('Example:'); | |
console.log(' npx ts-node benchmark-stageall.ts /path/to/my-repo'); | |
console.log(' npx ts-node benchmark-stageall.ts'); | |
process.exit(0); | |
} | |
try { | |
const benchmark = new StandaloneBenchmark(args[0]); | |
await benchmark.run(); | |
} catch (error: any) { | |
console.error(`${colors.red}Error: ${error.message}${colors.reset}`); | |
process.exit(1); | |
} | |
} | |
// Run main function if this file is executed directly | |
// Simple check that works in both CommonJS and ES modules | |
if (process.argv[1] && process.argv[1].endsWith('benchmark-stageall.ts')) { | |
main(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment