Skip to content

Instantly share code, notes, and snippets.

@ijin
Created June 15, 2025 17:25
Show Gist options
  • Save ijin/8946d83e77ddcb108d62c82073550eaf to your computer and use it in GitHub Desktop.
Save ijin/8946d83e77ddcb108d62c82073550eaf to your computer and use it in GitHub Desktop.
roo-benchmark-stageAll.ts
#!/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