diff --git a/common/autoinstallers/rush-commands/.gitignore b/common/autoinstallers/rush-commands/.gitignore new file mode 100644 index 000000000..5f37ca5d1 --- /dev/null +++ b/common/autoinstallers/rush-commands/.gitignore @@ -0,0 +1,2 @@ +lib +dist diff --git a/common/autoinstallers/rush-commands/package.json b/common/autoinstallers/rush-commands/package.json index 86056a747..2e0405989 100644 --- a/common/autoinstallers/rush-commands/package.json +++ b/common/autoinstallers/rush-commands/package.json @@ -1,20 +1,29 @@ { "name": "rush-commands", "version": "1.0.0", - "description": "", + "private": true, + "description": "Rush command tools and utilities", "keywords": [], "license": "Apache-2.0", "author": "", "main": "index.js", - "scripts": {}, + "scripts": { + "build": "tsc", + "clean": "rimraf lib/", + "revert-useless-changes": "node lib/revert-useless-changes/cli.js" + }, "dependencies": { - "commander": "^11.0.0", + "@typescript-eslint/parser": "^8.0.0", + "chalk": "^4.1.2", + "commander": "^12.0.0", + "glob": "^10.3.10", "simple-git": "^3.20.0" }, "devDependencies": { "@types/node": "^20.0.0", + "rimraf": "^5.0.0", + "sucrase": "^3.32.0", "tsx": "^4.19.2", "typescript": "^5.0.0" } } - diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes.js b/common/autoinstallers/rush-commands/src/revert-useless-changes.js new file mode 100644 index 000000000..2ccf7fea7 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes.js @@ -0,0 +1,5 @@ +require('sucrase/register'); + +const { main } = require('./revert-useless-changes/cli.ts'); + +main(); diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/README.md b/common/autoinstallers/rush-commands/src/revert-useless-changes/README.md new file mode 100644 index 000000000..fcb8d1974 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/README.md @@ -0,0 +1,244 @@ +# Revert Useless Changes + +A functional programming-based tool to automatically detect and revert files with only cosmetic changes (whitespace or comments) in Git repositories. + +## Architecture Overview + +### ๐Ÿ—๏ธ Core Design Principles + +- **Functional Programming**: Pure functions with minimal side effects +- **Rule-Based Analysis**: Extensible rule system for different change types +- **Immutable Data Flow**: Data flows through pure transformation functions +- **Type Safety**: Full TypeScript coverage with strict typing + +### ๐Ÿ“ฆ Module Structure + +``` +src/revert-useless-changes/ +โ”œโ”€โ”€ types.ts # Core type definitions +โ”œโ”€โ”€ config.ts # Constants and configuration +โ”œโ”€โ”€ utils/ # Pure utility functions +โ”‚ โ”œโ”€โ”€ git.ts # Git operations (getChangedFiles, revertFile, etc.) +โ”‚ โ””โ”€โ”€ file.ts # File system operations (exists, readFile, etc.) +โ”œโ”€โ”€ rules/ # Analysis rules +โ”‚ โ”œโ”€โ”€ index.ts # Rule registry and matching +โ”‚ โ”œโ”€โ”€ whitespace-rule.ts # Detects whitespace-only changes +โ”‚ โ””โ”€โ”€ ast-comment-rule.ts # Detects comment-only changes via AST +โ”œโ”€โ”€ reporter.ts # Output formatting (console, JSON) +โ”œโ”€โ”€ orchestrator.ts # Main workflow coordination +โ”œโ”€โ”€ cli.ts # Command-line interface (Commander.js) +โ””โ”€โ”€ index.ts # Public API exports +``` + +## ๐Ÿ”„ Core Workflow + +```mermaid +graph TD + A[CLI Input] --> B[Parse Arguments] + B --> C[Get Changed Files from Git] + C --> D[Filter Files by Include/Exclude] + D --> E[Analyze Each File] + E --> F[Apply Applicable Rules] + F --> G[Generate Analysis Report] + G --> H{Dry Run?} + H -->|Yes| I[Display Results] + H -->|No| J[Revert Matching Files] + J --> I[Display Results] +``` + +## ๐Ÿงฉ Key Modules + +### 1. **Orchestrator** (`orchestrator.ts`) +**Purpose**: Coordinates the entire analysis workflow + +**Core Function**: `execute(config: Config) -> Promise` + +**Flow**: +```typescript +1. getChangedFiles() -> string[] // Get Git changed files +2. filterFiles() -> string[] // Apply include/exclude patterns +3. analyzeFiles() -> FileAnalysis[] // Analyze each file with rules +4. generateReport() -> AnalysisReport // Create summary statistics +5. performReverts() -> AnalysisReport // Revert files (if not dry run) +``` + +### 2. **Rule System** (`rules/`) +**Purpose**: Pluggable analysis rules for different change types + +**Rule Interface**: +```typescript +interface Rule { + name: string; // Unique identifier + description: string; // Human-readable description + filePatterns: readonly string[]; // Glob patterns for applicable files +} + +type RuleAnalyzer = (filePath: string, config: Config) => Promise +``` + +**Built-in Rules**: +- **WhitespaceRule**: Uses `git diff -w` to detect whitespace-only changes +- **AstCommentRule**: Parses JS/TS with AST, removes comments, compares structure + +**Rule Registry**: +```typescript +getRulesForFile(filePath) -> RuleDefinition[] // Get applicable rules +getAllRules() -> RuleDefinition[] // Get all available rules +``` + +### 3. **Git Utilities** (`utils/git.ts`) +**Purpose**: Pure functions for Git operations + +**Key Functions**: +```typescript +getChangedFiles(options) -> string[] // List modified files +hasOnlyWhitespaceChanges(file, options) -> boolean +getFileContentAtRef(file, options) -> string // Get file at Git ref +revertFile(file, options) -> void // Revert single file +findGitRepositoryRoot(startDir) -> string | null // Find Git repo root +validateGitRepository(cwd) -> void // Validate Git repo or throw +``` + +### 4. **File Utilities** (`utils/file.ts`) +**Purpose**: Pure functions for file system operations + +**Key Functions**: +```typescript +exists(path) -> boolean // Check file existence +readFile(path) -> string // Read file content +matchesPattern(path, patterns) -> boolean // Pattern matching +toAbsolutePath(path, base) -> string // Path resolution +``` + +### 5. **Reporter** (`reporter.ts`) +**Purpose**: Output formatting and user feedback + +**Key Functions**: +```typescript +generateReport(report, config) -> void // Main report output +logProgress(message, config) -> void // Progress updates +logVerbose(message, config) -> void // Detailed logging +logError/logWarning/logSuccess(...) // Status messages +``` + +## ๐Ÿ” Analysis Process + +### File Analysis Flow +```typescript +1. Check file existence +2. Find applicable rules based on file patterns +3. Execute each rule analyzer function +4. Combine results: shouldRevert = any(rule.shouldRevert) +5. Create FileAnalysis with results and matched rule +``` + +### Rule Execution +```typescript +// Whitespace Rule +hasOnlyWhitespaceChanges(file) -> RuleResult { + shouldRevert: git diff -w shows no changes +} + +// AST Comment Rule +analyzeAstCommentRule(file) -> RuleResult { + 1. Parse current and previous versions with TypeScript parser + 2. Remove comment nodes from both ASTs + 3. Deep compare cleaned ASTs + 4. shouldRevert: ASTs are structurally identical +} +``` + +## ๐Ÿ“Š Data Flow + +### Configuration Flow +``` +CLI Arguments -> Config Object -> All Functions +``` + +### Analysis Flow +``` +Git Files -> File Filters -> Rule Analysis -> Report Generation -> Output +``` + +### Types Flow +```typescript +Config -> FileAnalysis[] -> AnalysisReport -> Console/JSON Output +``` + +## ๐ŸŽฏ Extension Points + +### Adding New Rules +```typescript +// 1. Define rule constant +export const NEW_RULE: Rule = { + name: 'new-rule', + description: 'Detects new type of changes', + filePatterns: ['**/*.ext'] +}; + +// 2. Implement analyzer function +export const analyzeNewRule = async (filePath: string, config: Config): Promise => { + // Analysis logic here + return { filePath, ruleName: NEW_RULE.name, shouldRevert: true/false }; +}; + +// 3. Register in rules/index.ts +export const AVAILABLE_RULES = [ + { rule: WHITESPACE_RULE, analyzer: analyzeWhitespaceRule }, + { rule: AST_COMMENT_RULE, analyzer: analyzeAstCommentRule }, + { rule: NEW_RULE, analyzer: analyzeNewRule } +]; +``` + +### Adding New Output Formats +```typescript +// Extend reporter.ts +export const generateXmlReport = (report: AnalysisReport) => { + // XML formatting logic +}; +``` + +## ๐Ÿงช Testing Strategy + +### Unit Testing +- Test each pure function in isolation +- Mock Git operations for deterministic tests +- Test rule analyzers with sample files + +### Integration Testing +- Test full workflow with temporary Git repos +- Test CLI argument parsing +- Test error handling scenarios + +## ๐Ÿš€ Usage + +### Programmatic API +```typescript +import { analyzeAndRevert } from './index'; + +const report = await analyzeAndRevert({ + cwd: '/path/to/project', + dryRun: true, + verbose: false +}); +``` + +### CLI Usage +```bash +# Analyze and show what would be reverted +revert-useless-changes --dry-run --verbose + +# Actually revert files with only whitespace/comment changes +revert-useless-changes + +# Analyze specific file types only +revert-useless-changes --include "**/*.ts" --include "**/*.js" +``` + +## ๐Ÿ’ก Key Benefits + +1. **Safe Operations**: Dry-run mode prevents accidental changes +2. **Intelligent Analysis**: AST-based comment detection, Git-based whitespace detection +3. **Extensible**: Easy to add new rule types +4. **Fast**: Functional approach enables efficient processing +5. **Reliable**: Pure functions are predictable and testable \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/cli.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/cli.ts new file mode 100644 index 000000000..cb94f4ff2 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/cli.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { Config } from './types'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; +import { validateGitRepository } from './utils/git'; +import { execute } from './orchestrator'; + +/** + * Command line interface for the revert-useless-changes tool + */ +export async function main(): Promise { + const program = new Command(); + + program + .name('revert-useless-changes') + .description( + 'Analyze and revert files with only whitespace or comment changes', + ) + .version('1.0.0') + .option('--cwd ', 'Working directory to analyze', process.cwd()) + .option( + '-d, --dry-run', + 'Show what would be reverted without actually reverting', + false, + ) + .option('-v, --verbose', 'Show verbose output during analysis', false) + .option('-j, --json', 'Output results in JSON format', false) + .option( + '--include ', + 'File patterns to include (glob patterns)', + [], + ) + .option( + '--exclude ', + 'File patterns to exclude (glob patterns)', + ['**/node_modules/**', '**/tmp/**', '**/.git/**'], + ) + .addHelpText( + 'after', + ` +Examples: + $ revert-useless-changes Analyze current directory and revert files + $ revert-useless-changes --dry-run Show what would be reverted without reverting + $ revert-useless-changes --cwd /path/to/project Analyze a specific directory + $ revert-useless-changes --verbose Show detailed analysis information + $ revert-useless-changes --json Output results in JSON format + $ revert-useless-changes --include "**/*.ts" --include "**/*.js" Only analyze TS/JS files + $ revert-useless-changes --exclude "**/test/**" Exclude test directories from analysis +`, + ); + + program.parse(); + const options = program.opts(); + + // Create configuration from command line arguments + const config: Config = { + cwd: resolve(options.cwd), + dryRun: options.dryRun, + verbose: options.verbose, + json: options.json, + include: + options.include && options.include.length > 0 + ? options.include + : undefined, + exclude: + options.exclude && options.exclude.length > 0 + ? options.exclude + : undefined, + }; + + try { + // Validate working directory exists + if (!existsSync(config.cwd)) { + console.error(`Error: Directory does not exist: ${config.cwd}`); + process.exit(1); + } + + // Check if we're in a git repository + validateGitRepository(config.cwd); + + // Run the analysis workflow + const report = await execute(config); + + // Set exit code based on results + if (report.revertErrors.length > 0) { + process.exit(1); + } + } catch (error) { + console.error( + 'Fatal error:', + error instanceof Error ? error.message : String(error), + ); + if (config.verbose && error instanceof Error && error.stack) { + console.error('Stack trace:', error.stack); + } + process.exit(1); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', error => { + console.error('Uncaught Exception:', error); + process.exit(1); +}); + +// Run the CLI +if (require.main === module) { + main().catch(error => { + console.error('CLI execution failed:', error); + process.exit(1); + }); +} diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/config.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/config.ts new file mode 100644 index 000000000..368e01def --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/config.ts @@ -0,0 +1,37 @@ +import { Config } from './types'; + +/** + * Default configuration values + */ +export const DEFAULT_CONFIG: Omit = { + dryRun: false, + verbose: false, + json: false, + exclude: [ + 'node_modules/**', + '.git/**', + 'tmp/**', + '**/*.log', + '**/.DS_Store', + '**/dist/**', + '**/build/**', + '**/lib/**', + ] +}; + +/** + * File patterns for different rule types + */ +export const FILE_PATTERNS = { + TYPESCRIPT_JAVASCRIPT: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + ALL_FILES: ['**/*'], +} as const; + +/** + * Constants for the tool + */ +export const CONSTANTS = { + TOOL_NAME: 'revert-useless-changes', + VERSION: '1.0.0', + DEFAULT_GIT_REF: 'HEAD', +} as const; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/index.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/index.ts new file mode 100644 index 000000000..b4327f28a --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/index.ts @@ -0,0 +1,46 @@ +/** + * Main entry point for revert-useless-changes tool + */ + +// Export main types +export type { + Config, + Rule, + RuleResult, + FileAnalysis, + AnalysisReport, + RevertError, + ChangeType +} from './types'; + +// Export main orchestrator functions +export { execute } from './orchestrator'; + +// Export CLI +export { main as runCLI } from './cli'; + +// Export reporter functions +export * as reporter from './reporter'; + +// Export rules and rule registry +export * from './rules'; + +// Export utilities +export * as gitUtils from './utils/git'; +export * as fileUtils from './utils/file'; + +// Export configuration constants +export { FILE_PATTERNS } from './config'; + +/** + * Programmatic API for analyzing and reverting files + */ +export async function analyzeAndRevert(config: import('./types').Config): Promise { + const { execute } = await import('./orchestrator'); + return execute(config); +} + +/** + * Default export for CLI usage + */ +export default { analyzeAndRevert }; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/orchestrator.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/orchestrator.ts new file mode 100644 index 000000000..7bee82713 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/orchestrator.ts @@ -0,0 +1,297 @@ +import { Config, AnalysisReport, FileAnalysis, RevertError } from './types'; +import { getRulesForFile } from './rules'; +import { getChangedFiles, revertFile } from './utils/git'; +import { toAbsolutePath, toRelativePath, exists, matchesPattern } from './utils/file'; +import * as reporter from './reporter'; + +/** + * Execute the full analysis and revert workflow + */ +export const execute = async (config: Config): Promise => { + reporter.logProgress('Starting analysis...', config); + + // Get list of changed files from Git + const changedFiles = getChangedFilesFiltered(config); + reporter.logProgress(`Found ${changedFiles.length} changed files`, config); + + if (changedFiles.length === 0) { + reporter.logWarning('No changed files found. Nothing to analyze.', config); + return createEmptyReport(config); + } + + // Analyze each file + const fileAnalyses = await analyzeFiles(changedFiles, config); + + // Generate report + const report = generateReport(fileAnalyses, config); + + // Perform reverts if not dry run + const updatedReport = !config.dryRun + ? await performReverts(report, config) + : report; + + // Display results + reporter.generateReport(updatedReport, config); + + return updatedReport; +}; + +/** + * Get list of changed files from Git with filtering applied + */ +const getChangedFilesFiltered = (config: Config): string[] => { + try { + const files = getChangedFiles({ cwd: config.cwd }); + + // Filter out files based on patterns if specified + let filteredFiles = files; + + if (config.include && config.include.length > 0) { + filteredFiles = filteredFiles.filter(file => + matchesPattern(file, config.include!) + ); + } + + if (config.exclude && config.exclude.length > 0) { + filteredFiles = filteredFiles.filter(file => + !matchesPattern(file, config.exclude!) + ); + } + + return filteredFiles; + } catch (error) { + reporter.logError(`Failed to get changed files: ${error instanceof Error ? error.message : String(error)}`, config); + return []; + } +}; + +/** + * Analyze all files using applicable rules + */ +const analyzeFiles = async (filePaths: string[], config: Config): Promise => { + const analyses: FileAnalysis[] = []; + + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; + reporter.logProgress(`Analyzing file ${i + 1}/${filePaths.length}: ${toRelativePath(filePath, config.cwd)}`, config); + + try { + const analysis = await analyzeFile(filePath, config); + analyses.push(analysis); + } catch (error) { + reporter.logError(`Failed to analyze ${filePath}: ${error instanceof Error ? error.message : String(error)}`, config); + + // Create error analysis + analyses.push(createErrorAnalysis(filePath, error)); + } + } + + return analyses; +}; + +/** + * Analyze a single file using applicable rules + */ +const analyzeFile = async (filePath: string, config: Config): Promise => { + const absolutePath = toAbsolutePath(filePath, config.cwd); + const fileExists = exists(absolutePath); + + reporter.logVerbose(`Checking if file exists: ${absolutePath} = ${fileExists}`, config); + + if (!fileExists) { + return createDeletedFileAnalysis(absolutePath); + } + + // Get rules that apply to this file + const applicableRules = getRulesForFile(absolutePath); + reporter.logVerbose(`Found ${applicableRules.length} applicable rules for ${absolutePath}`, config); + + if (applicableRules.length === 0) { + return createNoRulesAnalysis(absolutePath); + } + + // Apply each rule + const ruleResults = []; + let shouldRevert = false; + let matchedRule: string | undefined = undefined; + + for (const { rule, analyzer } of applicableRules) { + reporter.logVerbose(`Applying rule: ${rule.name}`, config); + + try { + const result = await analyzer(absolutePath, config); + ruleResults.push(result); + + // If any rule says we should revert, we should revert + if (result.shouldRevert && !shouldRevert) { + shouldRevert = true; + matchedRule = rule.name; + } + } catch (error) { + reporter.logVerbose(`Rule ${rule.name} failed: ${error}`, config); + ruleResults.push(createRuleErrorResult(absolutePath, rule.name, error)); + } + } + + return { + filePath: absolutePath, + exists: true, + shouldRevert, + matchedRule, + ruleResults + }; +}; + +/** + * Create analysis for a deleted file + */ +const createDeletedFileAnalysis = (filePath: string): FileAnalysis => ({ + filePath, + exists: false, + shouldRevert: false, + matchedRule: undefined, + ruleResults: [{ + filePath, + ruleName: 'file-deleted', + shouldRevert: false, + reason: 'File was deleted' + }] +}); + +/** + * Create analysis for a file with no applicable rules + */ +const createNoRulesAnalysis = (filePath: string): FileAnalysis => ({ + filePath, + exists: true, + shouldRevert: false, + matchedRule: undefined, + ruleResults: [{ + filePath, + ruleName: 'no-rules', + shouldRevert: false, + reason: 'No applicable rules found for this file type' + }] +}); + +/** + * Create error analysis for a file that failed to analyze + */ +const createErrorAnalysis = (filePath: string, error: unknown): FileAnalysis => ({ + filePath, + exists: exists(filePath), + shouldRevert: false, + matchedRule: undefined, + ruleResults: [{ + filePath, + ruleName: 'analysis-error', + shouldRevert: false, + reason: 'Analysis failed', + error: error instanceof Error ? error.message : String(error) + }] +}); + +/** + * Create error result for a rule that failed + */ +const createRuleErrorResult = (filePath: string, ruleName: string, error: unknown) => ({ + filePath, + ruleName, + shouldRevert: false, + reason: 'Rule execution failed', + error: error instanceof Error ? error.message : String(error) +}); + +/** + * Generate analysis report from file analyses + */ +const generateReport = (fileAnalyses: FileAnalysis[], config: Config): AnalysisReport => { + const summary = { + totalFiles: fileAnalyses.length, + revertableFiles: fileAnalyses.filter(f => f.shouldRevert).length, + whitespaceOnlyFiles: fileAnalyses.filter(f => f.matchedRule === 'whitespace-only').length, + commentOnlyFiles: fileAnalyses.filter(f => f.matchedRule === 'ast-comment-only').length, + unchangedFiles: fileAnalyses.filter(f => !f.shouldRevert && f.exists).length, + deletedFiles: fileAnalyses.filter(f => !f.exists).length, + errorFiles: fileAnalyses.filter(f => f.ruleResults.some(r => r.error)).length + }; + + return { + timestamp: new Date().toISOString(), + config, + summary, + fileAnalyses, + revertedFiles: [], + revertErrors: [] + }; +}; + +/** + * Perform file reverts based on analysis + */ +const performReverts = async (report: AnalysisReport, config: Config): Promise => { + const filesToRevert = report.fileAnalyses + .filter(analysis => analysis.shouldRevert) + .map(analysis => analysis.filePath); + + if (filesToRevert.length === 0) { + reporter.logProgress('No files to revert.', config); + return report; + } + + reporter.logProgress(`Reverting ${filesToRevert.length} files...`, config); + + const revertedFiles: string[] = []; + const revertErrors: RevertError[] = []; + + for (const filePath of filesToRevert) { + try { + reporter.logVerbose(`Reverting: ${toRelativePath(filePath, config.cwd)}`, config); + + revertFile(filePath, { cwd: config.cwd }); + revertedFiles.push(filePath); + + reporter.logVerbose(`Successfully reverted: ${filePath}`, config); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + revertErrors.push({ file: filePath, error: errorMessage }); + + reporter.logError(`Failed to revert ${filePath}: ${errorMessage}`, config); + } + } + + if (revertedFiles.length > 0) { + reporter.logSuccess(`Successfully reverted ${revertedFiles.length} files`, config); + } + + if (revertErrors.length > 0) { + reporter.logWarning(`Failed to revert ${revertErrors.length} files`, config); + } + + // Return updated report + return { + ...report, + revertedFiles, + revertErrors + }; +}; + +/** + * Create an empty report for when no files are found + */ +const createEmptyReport = (config: Config): AnalysisReport => ({ + timestamp: new Date().toISOString(), + config, + summary: { + totalFiles: 0, + revertableFiles: 0, + whitespaceOnlyFiles: 0, + commentOnlyFiles: 0, + unchangedFiles: 0, + deletedFiles: 0, + errorFiles: 0 + }, + fileAnalyses: [], + revertedFiles: [], + revertErrors: [] +}); \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/reporter.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/reporter.ts new file mode 100644 index 000000000..da5f790b1 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/reporter.ts @@ -0,0 +1,216 @@ +import { AnalysisReport, Config } from './types'; +import { toRelativePath } from './utils/file'; +import chalk from 'chalk'; + +/** + * Generate and display the analysis report + */ +export const generateReport = (report: AnalysisReport, config: Config): void => { + if (config.json) { + outputJson(report); + } else { + outputConsole(report, config); + } +}; + +/** + * Log progress during analysis + */ +export const logProgress = (message: string, config: Config): void => { + if (!config.json) { + console.log(chalk.blue('๐Ÿ”'), message); + } +}; + +/** + * Log verbose messages + */ +export const logVerbose = (message: string, config: Config): void => { + if (config.verbose && !config.json) { + console.log(chalk.gray(`[VERBOSE] ${message}`)); + } +}; + +/** + * Log error messages + */ +export const logError = (message: string, config: Config): void => { + if (!config.json) { + console.error(chalk.red('โŒ'), message); + } +}; + +/** + * Log warning messages + */ +export const logWarning = (message: string, config: Config): void => { + if (!config.json) { + console.warn(chalk.yellow('โš ๏ธ'), message); + } +}; + +/** + * Log success messages + */ +export const logSuccess = (message: string, config: Config): void => { + if (!config.json) { + console.log(chalk.green('โœ…'), message); + } +}; + +/** + * Output results as JSON + */ +const outputJson = (report: AnalysisReport): void => { + console.log(JSON.stringify(report, null, 2)); +}; + +/** + * Output results to console with formatting + */ +const outputConsole = (report: AnalysisReport, config: Config): void => { + console.log(); + console.log(chalk.bold('๐Ÿ“Š ANALYSIS REPORT')); + console.log('='.repeat(60)); + + // Summary + outputSummary(report); + + // File categorization + outputFileCategorization(report, config); + + // Revert results (if not dry run) + if (!config.dryRun) { + outputRevertResults(report, config); + } + + // Footer + outputFooter(report, config); +}; + +/** + * Output summary statistics + */ +const outputSummary = (report: AnalysisReport): void => { + console.log(chalk.bold('\n๐Ÿ“ˆ Summary:')); + console.log(`${chalk.blue('๐Ÿ“ Total files analyzed:')} ${report.summary.totalFiles}`); + console.log(`${chalk.green('๐Ÿ”„ Revertable files:')} ${report.summary.revertableFiles}`); + console.log(` ${chalk.cyan('โ”œโ”€ Whitespace-only:')} ${report.summary.whitespaceOnlyFiles}`); + console.log(` ${chalk.cyan('โ””โ”€ Comment-only:')} ${report.summary.commentOnlyFiles}`); + console.log(`${chalk.yellow('๐Ÿ“ Files with changes:')} ${report.summary.unchangedFiles}`); + console.log(`${chalk.red('๐Ÿ—‘๏ธ Deleted files:')} ${report.summary.deletedFiles}`); + console.log(`${chalk.red('โŒ Error files:')} ${report.summary.errorFiles}`); +}; + +/** + * Output file categorization + */ +const outputFileCategorization = (report: AnalysisReport, config: Config): void => { + const revertableFiles = report.fileAnalyses.filter(f => f.shouldRevert); + const codeChangeFiles = report.fileAnalyses.filter(f => !f.shouldRevert && f.exists); + + if (revertableFiles.length > 0) { + console.log(chalk.bold('\n๐Ÿ”„ REVERTABLE FILES:')); + console.log('-'.repeat(60)); + revertableFiles.slice(0, 15).forEach((analysis, index) => { + const relativePath = toRelativePath(analysis.filePath, config.cwd); + const ruleType = analysis.matchedRule === 'whitespace-only' ? '๐Ÿ”ค Whitespace' : '๐Ÿ’ฌ Comments'; + console.log(`${(index + 1).toString().padStart(3)}. ${ruleType} - ${relativePath}`); + }); + + if (revertableFiles.length > 15) { + console.log(`... and ${revertableFiles.length - 15} more files`); + } + } + + if (codeChangeFiles.length > 0) { + console.log(chalk.bold('\n๐Ÿ“ FILES WITH CODE CHANGES (keeping):')); + console.log('-'.repeat(60)); + codeChangeFiles.slice(0, 10).forEach((analysis, index) => { + const relativePath = toRelativePath(analysis.filePath, config.cwd); + console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`); + }); + + if (codeChangeFiles.length > 10) { + console.log(`... and ${codeChangeFiles.length - 10} more files`); + } + } + + const deletedFiles = report.fileAnalyses.filter(f => !f.exists); + if (deletedFiles.length > 0) { + console.log(chalk.bold('\n๐Ÿ—‘๏ธ DELETED FILES:')); + console.log('-'.repeat(60)); + deletedFiles.forEach((analysis, index) => { + const relativePath = toRelativePath(analysis.filePath, config.cwd); + console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`); + }); + } + + const errorFiles = report.fileAnalyses.filter(f => + f.ruleResults.some(r => r.error) + ); + if (errorFiles.length > 0) { + console.log(chalk.bold('\nโŒ ERROR FILES:')); + console.log('-'.repeat(60)); + errorFiles.forEach((analysis, index) => { + const relativePath = toRelativePath(analysis.filePath, config.cwd); + const errors = analysis.ruleResults.filter(r => r.error).map(r => r.error).join('; '); + console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`); + console.log(` Error: ${errors}`); + }); + } +}; + +/** + * Output revert operation results + */ +const outputRevertResults = (report: AnalysisReport, config: Config): void => { + if (report.revertedFiles.length > 0 || report.revertErrors.length > 0) { + console.log(chalk.bold('\n๐Ÿ”„ REVERT RESULTS:')); + console.log('='.repeat(60)); + + if (report.revertedFiles.length > 0) { + console.log(`${chalk.green('โœ… Successfully reverted:')} ${report.revertedFiles.length} files`); + if (config.verbose) { + report.revertedFiles.forEach(file => { + const relativePath = toRelativePath(file, config.cwd); + console.log(` - ${relativePath}`); + }); + } + } + + if (report.revertErrors.length > 0) { + console.log(`${chalk.red('โŒ Failed to revert:')} ${report.revertErrors.length} files`); + report.revertErrors.forEach(({ file, error }) => { + const relativePath = toRelativePath(file, config.cwd); + console.log(` - ${relativePath}: ${error}`); + }); + } + } +}; + +/** + * Output footer with recommendations + */ +const outputFooter = (report: AnalysisReport, config: Config): void => { + console.log(chalk.bold('\n๐ŸŽฏ RECOMMENDATIONS:')); + console.log('='.repeat(60)); + + if (config.dryRun && report.summary.revertableFiles > 0) { + console.log(chalk.green(`โœ… Found ${report.summary.revertableFiles} files that can be safely reverted`)); + console.log(chalk.cyan('๐Ÿ’ก Run without --dry-run to actually revert these files')); + } else if (!config.dryRun && report.revertedFiles.length > 0) { + console.log(chalk.green(`โœ… Successfully reverted ${report.revertedFiles.length} files`)); + console.log(chalk.cyan("๐Ÿ’ก Run 'git status' to see remaining changes")); + } + + if (report.summary.unchangedFiles > 0) { + console.log(chalk.yellow(`โš ๏ธ ${report.summary.unchangedFiles} files have actual code/content changes and were kept`)); + } + + if (report.summary.errorFiles > 0) { + console.log(chalk.red(`โŒ ${report.summary.errorFiles} files had analysis errors`)); + } + + console.log(); +}; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/ast-comment-rule.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/ast-comment-rule.ts new file mode 100644 index 000000000..3a17a73df --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/ast-comment-rule.ts @@ -0,0 +1,185 @@ +import { RuleResult, Config, Rule } from '../types'; +import { FILE_PATTERNS } from '../config'; +import { readFile, isJavaScriptOrTypeScript, exists } from '../utils/file'; +import { getFileContentAtRef, isNewFile } from '../utils/git'; +import { parse } from '@typescript-eslint/parser'; + +/** + * Rule definition for detecting comment-only changes using AST + */ +export const AST_COMMENT_RULE: Rule = { + name: 'ast-comment-only', + description: 'Detects JS/TS files that have only comment changes using AST comparison', + filePatterns: FILE_PATTERNS.TYPESCRIPT_JAVASCRIPT +} as const; + +/** + * Create a result object for the AST comment rule + */ +const createResult = ( + filePath: string, + shouldRevert: boolean, + reason?: string, + error?: string +): RuleResult => ({ + filePath, + ruleName: AST_COMMENT_RULE.name, + shouldRevert, + reason, + error +}); + +/** + * Log verbose messages if enabled + */ +const log = (message: string, config: Config): void => { + if (config.verbose) { + console.log(`[${AST_COMMENT_RULE.name}] ${message}`); + } +}; + +/** + * Parse file content and return AST with comments removed + */ +const parseWithoutComments = (filePath: string, content: string): any => { + const isTypeScript = filePath.match(/\.(ts|tsx)$/); + const isJSX = filePath.match(/\.(tsx|jsx)$/); + + const ast = parse(content, { + loc: true, + range: true, + comment: true, + ecmaVersion: 'latest' as any, + sourceType: 'module', + ecmaFeatures: { + jsx: !!isJSX, + }, + ...(isTypeScript && { + filePath, + }), + }); + + return removeComments(ast); +}; + +/** + * Recursively remove comment-related properties from AST nodes + */ +const removeComments = (node: any): any => { + if (!node || typeof node !== 'object') { + return node; + } + + if (Array.isArray(node)) { + return node.map(item => removeComments(item)); + } + + const cleaned: any = {}; + for (const [key, value] of Object.entries(node)) { + // Skip comment-related properties + if (key === 'comments' || key === 'leadingComments' || key === 'trailingComments') { + continue; + } + // Skip location information that might differ due to comments + if (key === 'range' || key === 'loc' || key === 'start' || key === 'end') { + continue; + } + + cleaned[key] = removeComments(value); + } + + return cleaned; +}; + +/** + * Deep equality comparison of two objects + */ +const deepEqual = (obj1: any, obj2: any): boolean => { + if (obj1 === obj2) return true; + + if (obj1 == null || obj2 == null) return obj1 === obj2; + + if (typeof obj1 !== typeof obj2) return false; + + if (typeof obj1 !== 'object') return obj1 === obj2; + + if (Array.isArray(obj1) !== Array.isArray(obj2)) return false; + + if (Array.isArray(obj1)) { + if (obj1.length !== obj2.length) return false; + for (let i = 0; i < obj1.length; i++) { + if (!deepEqual(obj1[i], obj2[i])) return false; + } + return true; + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!keys2.includes(key)) return false; + if (!deepEqual(obj1[key], obj2[key])) return false; + } + + return true; +}; + +/** + * Analyze a file for comment-only changes using AST comparison + */ +export const analyzeAstCommentRule = async (filePath: string, config: Config): Promise => { + log(`Analyzing file for comment-only changes: ${filePath}`, config); + + try { + // Check if file exists + if (!exists(filePath)) { + return createResult(filePath, false, 'File does not exist'); + } + + // Check if it's a JS/TS file + if (!isJavaScriptOrTypeScript(filePath)) { + return createResult(filePath, false, 'Not a JavaScript/TypeScript file'); + } + + // Skip new files - they don't have a previous version to compare + if (isNewFile(filePath, { cwd: config.cwd })) { + return createResult(filePath, false, 'File is newly added, skipping analysis'); + } + + // Get current file content + const currentContent = readFile(filePath); + + // Get previous file content from git + const previousContent = getFileContentAtRef(filePath, { cwd: config.cwd }); + + // Parse both versions and compare ASTs without comments + const currentAst = parseWithoutComments(filePath, currentContent); + const previousAst = parseWithoutComments(filePath, previousContent); + + // Compare ASTs + const astEqual = deepEqual(currentAst, previousAst); + + if (astEqual) { + return createResult( + filePath, + true, + 'File has only comment changes based on AST comparison' + ); + } else { + return createResult( + filePath, + false, + 'File has code changes beyond comments' + ); + } + } catch (error) { + return createResult( + filePath, + false, + 'Failed to perform AST comparison', + error instanceof Error ? error.message : String(error) + ); + } +}; diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/index.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/index.ts new file mode 100644 index 000000000..f1a85706b --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/index.ts @@ -0,0 +1,62 @@ +import { Rule, RuleAnalyzer } from '../types'; +import { WHITESPACE_RULE, analyzeWhitespaceRule } from './whitespace-rule'; +import { AST_COMMENT_RULE, analyzeAstCommentRule } from './ast-comment-rule'; + +/** + * Registry of all available rules with their analyzers + */ +export interface RuleDefinition { + rule: Rule; + analyzer: RuleAnalyzer; +} + +/** + * All available rules with their analysis functions + */ +export const AVAILABLE_RULES: readonly RuleDefinition[] = [ + { rule: WHITESPACE_RULE, analyzer: analyzeWhitespaceRule }, + { rule: AST_COMMENT_RULE, analyzer: analyzeAstCommentRule } +] as const; + +/** + * Get all available rules + */ +export const getAllRules = (): readonly RuleDefinition[] => { + return AVAILABLE_RULES; +}; + +/** + * Get rules that apply to a specific file + */ +export const getRulesForFile = (filePath: string): readonly RuleDefinition[] => { + return AVAILABLE_RULES.filter(({ rule }) => + rule.filePatterns.some(pattern => matchesPattern(filePath, pattern)) + ); +}; + +/** + * Get a rule by name + */ +export const getRuleByName = (name: string): RuleDefinition | undefined => { + return AVAILABLE_RULES.find(({ rule }) => rule.name === name); +}; + +/** + * Simple pattern matching logic + */ +const matchesPattern = (filePath: string, pattern: string): boolean => { + if (pattern === '**/*') return true; + + // Simple extension matching for patterns like '**/*.ts' + const extensionMatch = pattern.match(/\*\*\/\*\.(\w+)$/); + if (extensionMatch && extensionMatch[1]) { + return filePath.endsWith(`.${extensionMatch[1]}`); + } + + // More complex pattern matching could be implemented here + return false; +}; + +// Re-export rule constants and analyzers for convenience +export { WHITESPACE_RULE, analyzeWhitespaceRule } from './whitespace-rule'; +export { AST_COMMENT_RULE, analyzeAstCommentRule } from './ast-comment-rule'; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/whitespace-rule.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/whitespace-rule.ts new file mode 100644 index 000000000..4f268319c --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/rules/whitespace-rule.ts @@ -0,0 +1,75 @@ +import { RuleResult, Config, Rule } from '../types'; +import { FILE_PATTERNS } from '../config'; +import { hasOnlyWhitespaceChanges } from '../utils/git'; +import { exists } from '../utils/file'; + +/** + * Rule definition for detecting whitespace-only changes + */ +export const WHITESPACE_RULE: Rule = { + name: 'whitespace-only', + description: 'Detects files that have only whitespace changes (spaces, tabs, newlines)', + filePatterns: FILE_PATTERNS.ALL_FILES +} as const; + +/** + * Create a result object for the whitespace rule + */ +const createResult = ( + filePath: string, + shouldRevert: boolean, + reason?: string, + error?: string +): RuleResult => ({ + filePath, + ruleName: WHITESPACE_RULE.name, + shouldRevert, + reason, + error +}); + +/** + * Log verbose messages if enabled + */ +const log = (message: string, config: Config): void => { + if (config.verbose) { + console.log(`[${WHITESPACE_RULE.name}] ${message}`); + } +}; + +/** + * Analyze a file for whitespace-only changes + */ +export const analyzeWhitespaceRule = async (filePath: string, config: Config): Promise => { + log(`Analyzing file for whitespace changes: ${filePath}`, config); + + try { + // Check if file exists + if (!exists(filePath)) { + return createResult(filePath, false, 'File does not exist'); + } + + const hasOnlyWhitespace = hasOnlyWhitespaceChanges(filePath, { cwd: config.cwd }); + + if (hasOnlyWhitespace) { + return createResult( + filePath, + true, + 'File has only whitespace changes (spaces, tabs, newlines)' + ); + } else { + return createResult( + filePath, + false, + 'File has non-whitespace changes' + ); + } + } catch (error) { + return createResult( + filePath, + false, + 'Failed to check whitespace changes', + error instanceof Error ? error.message : String(error) + ); + } +}; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/types.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/types.ts new file mode 100644 index 000000000..dd5491a01 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/types.ts @@ -0,0 +1,124 @@ +/** + * Configuration options for the revert-useless-changes tool + */ +export interface Config { + /** Working directory to analyze */ + cwd: string; + /** Only analyze, don't actually revert files */ + dryRun: boolean; + /** Enable verbose output */ + verbose: boolean; + /** Output results as JSON */ + json: boolean; + /** File patterns to include in analysis */ + include?: string[]; + /** File patterns to exclude from analysis */ + exclude?: string[]; +} + +/** + * Result of analyzing a single file with a rule + */ +export interface RuleResult { + /** The file that was analyzed */ + filePath: string; + /** Name of the rule that was applied */ + ruleName: string; + /** Whether this rule matched (file should be reverted) */ + shouldRevert: boolean; + /** Optional reason why the rule matched or didn't match */ + reason?: string; + /** Any error that occurred during analysis */ + error?: string; +} + +/** + * Analysis results for a single file across all applicable rules + */ +export interface FileAnalysis { + /** The file that was analyzed */ + filePath: string; + /** Results from each rule that was applied */ + ruleResults: RuleResult[]; + /** Whether any rule matched (file should be reverted) */ + shouldRevert: boolean; + /** The rule that matched (if any) */ + matchedRule?: string; + /** Whether the file exists (not deleted) */ + exists: boolean; +} + +/** + * Overall analysis report + */ +export interface AnalysisReport { + /** Timestamp when analysis was performed */ + timestamp: string; + /** Configuration used for analysis */ + config: Config; + /** Summary statistics */ + summary: { + totalFiles: number; + revertableFiles: number; + whitespaceOnlyFiles: number; + commentOnlyFiles: number; + deletedFiles: number; + errorFiles: number; + unchangedFiles: number; + }; + /** Detailed analysis for each file */ + fileAnalyses: FileAnalysis[]; + /** Files that were successfully reverted (if not dry run) */ + revertedFiles: string[]; + /** Files that failed to revert */ + revertErrors: RevertError[]; +} + +/** + * Error information for files that failed to revert + */ +export interface RevertError { + /** File that failed to revert */ + file: string; + /** Error message */ + error: string; +} + +/** + * File change type classification + */ +export enum ChangeType { + WHITESPACE_ONLY = 'whitespace-only', + COMMENT_ONLY = 'comment-only', + CODE_CHANGES = 'code-changes', + DELETED = 'deleted', + ERROR = 'error', + UNCHANGED = 'unchanged' +} + +/** + * Rule definition for analyzing files + */ +export interface Rule { + /** Unique name of the rule */ + readonly name: string; + /** Description of what the rule detects */ + readonly description: string; + /** File patterns this rule applies to (glob patterns) */ + readonly filePatterns: readonly string[]; +} + +/** + * Function type for analyzing files with a rule + */ +export type RuleAnalyzer = (filePath: string, config: Config) => Promise; + +/** + * Options for git operations + */ +export interface GitOptions { + /** Working directory for git operations */ + cwd: string; + /** Git reference to compare against (default: HEAD) */ + ref?: string; +} \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/file.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/file.ts new file mode 100644 index 000000000..a4b34526f --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/file.ts @@ -0,0 +1,110 @@ +import { readFileSync, existsSync, statSync } from 'fs'; +import { join, relative, isAbsolute } from 'path'; +import { glob } from 'glob'; + +/** + * Read file content safely + */ +export const readFile = (filePath: string): string => { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Check if file exists + */ +export const exists = (filePath: string): boolean => { + return existsSync(filePath); +}; + +/** + * Convert relative path to absolute path + */ +export const toAbsolutePath = (filePath: string, basePath: string): string => { + if (isAbsolute(filePath)) { + return filePath; + } + return join(basePath, filePath); +}; + +/** + * Convert absolute path to relative path + */ +export const toRelativePath = (filePath: string, basePath: string): string => { + return relative(basePath, filePath); +}; + +/** + * Match files against glob patterns + */ +export const matchFiles = (patterns: string[], options: { cwd: string; exclude?: string[] }): string[] => { + const allMatches: string[] = []; + + for (const pattern of patterns) { + try { + const matches = glob.sync(pattern, { + cwd: options.cwd, + absolute: true, + ignore: options.exclude || [], + nodir: true + }); + allMatches.push(...matches); + } catch (error) { + console.warn(`Warning: Failed to match pattern ${pattern}:`, error); + } + } + + // Remove duplicates + return Array.from(new Set(allMatches)); +}; + +/** + * Check if a file matches any of the given patterns + */ +export const matchesPattern = (filePath: string, patterns: string[]): boolean => { + return patterns.some(pattern => { + if (pattern === '**/*') return true; + + // Simple extension matching for patterns like '**/*.ts' + const extensionMatch = pattern.match(/\*\*\/\*\.(\w+)$/); + if (extensionMatch && extensionMatch[1]) { + return filePath.endsWith(`.${extensionMatch[1]}`); + } + + // More complex pattern matching could be implemented here + // For now, we'll use simple string matching + return filePath.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, '')); + }); +}; + +/** + * Get file extension + */ +export const getExtension = (filePath: string): string => { + const lastDot = filePath.lastIndexOf('.'); + if (lastDot === -1 || lastDot === 0) return ''; + return filePath.substring(lastDot + 1); +}; + +/** + * Check if file is a JavaScript/TypeScript file + */ +export const isJavaScriptOrTypeScript = (filePath: string): boolean => { + const ext = getExtension(filePath).toLowerCase(); + return ['js', 'jsx', 'ts', 'tsx'].includes(ext); +}; + +/** + * Get file size in bytes + */ +export const getFileSize = (filePath: string): number => { + try { + const stats = statSync(filePath); + return stats.size; + } catch { + return 0; + } +}; \ No newline at end of file diff --git a/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/git.ts b/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/git.ts new file mode 100644 index 000000000..638e5e894 --- /dev/null +++ b/common/autoinstallers/rush-commands/src/revert-useless-changes/utils/git.ts @@ -0,0 +1,217 @@ +import { execSync } from 'child_process'; +import { resolve, join, dirname } from 'path'; +import { existsSync } from 'fs'; +import { GitOptions } from '../types'; + +/** + * Get the project root directory (git repository root) + */ +export const getProjectRoot = (): string => { + try { + return execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + } catch (error) { + throw new Error('Not in a git repository or git is not available'); + } +}; + +/** + * Get list of changed files in git working directory (includes both modified and added files) + */ +export const getChangedFiles = (options: GitOptions = { cwd: process.cwd() }): string[] => { + try { + // Get both modified files and added files + const modifiedOutput = execSync('git diff --name-only', { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + + const addedOutput = execSync('git diff --cached --name-only', { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + + const modifiedFiles = modifiedOutput + .split('\n') + .map(file => file.trim()) + .filter(file => file.length > 0); + + const addedFiles = addedOutput + .split('\n') + .map(file => file.trim()) + .filter(file => file.length > 0); + + // Combine and deduplicate + const allFiles = Array.from(new Set([...modifiedFiles, ...addedFiles])); + + return allFiles.map(file => resolve(options.cwd, file)); + } catch (error) { + throw new Error(`Failed to get changed files: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Get list of modified files relative to a git reference + */ +export const getModifiedFiles = (options: GitOptions = { cwd: process.cwd() }): string[] => { + try { + const output = execSync(`git diff --name-only ${options.ref || 'HEAD'}`, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + + return output + .split('\n') + .map(file => file.trim()) + .filter(file => file.length > 0); + } catch (error) { + throw new Error(`Failed to get modified files: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Get file content from a specific git reference + */ +export const getFileContentAtRef = (filePath: string, options: GitOptions): string => { + try { + const projectRoot = getProjectRoot(); + const relativePath = filePath.startsWith(projectRoot) + ? filePath.substring(projectRoot.length + 1) + : filePath; + + return execSync(`git show ${options.ref || 'HEAD'}:${relativePath}`, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + } catch (error) { + throw new Error(`Failed to get file content at ref: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Check if a file is newly added (not in HEAD) + */ +export const isNewFile = (filePath: string, options: GitOptions): boolean => { + try { + const projectRoot = getProjectRoot(); + const relativePath = filePath.startsWith(projectRoot) + ? filePath.substring(projectRoot.length + 1) + : filePath; + + execSync(`git show ${options.ref || 'HEAD'}:${relativePath}`, { + cwd: options.cwd, + stdio: ['pipe', 'pipe', 'ignore'] + }); + + // If git show succeeds, the file exists in HEAD, so it's not new + return false; + } catch (error) { + // If git show fails, the file doesn't exist in HEAD, so it's new + return true; + } +}; + +/** + * Check if a file has only whitespace changes + */ +export const hasOnlyWhitespaceChanges = (filePath: string, options: GitOptions): boolean => { + try { + // Skip analysis for new files + if (isNewFile(filePath, options)) { + return false; + } + + const projectRoot = getProjectRoot(); + const relativePath = filePath.startsWith(projectRoot) + ? filePath.substring(projectRoot.length + 1) + : filePath; + + const output = execSync(`git diff -w ${options.ref || 'HEAD'} -- "${relativePath}"`, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + + return output.trim() === ''; + } catch (error) { + // If git diff fails, assume the file has changes + return false; + } +}; + +/** + * Revert a file to its state in the git reference + */ +export const revertFile = (filePath: string, options: GitOptions): void => { + try { + const projectRoot = getProjectRoot(); + const relativePath = filePath.startsWith(projectRoot) + ? filePath.substring(projectRoot.length + 1) + : filePath; + + execSync(`git checkout ${options.ref || 'HEAD'} -- "${relativePath}"`, { + cwd: options.cwd, + stdio: ['pipe', 'pipe', 'pipe'] + }); + } catch (error) { + throw new Error(`Failed to revert file: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Check if we're in a git repository + */ +export const isGitRepository = (cwd: string = process.cwd()): boolean => { + try { + execSync('git rev-parse --git-dir', { + cwd, + stdio: ['pipe', 'pipe', 'ignore'] + }); + return true; + } catch { + return false; + } +}; + +/** + * Find git repository root by recursively searching for .git directory + * @param startDir Directory to start searching from + * @returns Git repository root path or null if not found + */ +export const findGitRepositoryRoot = (startDir: string): string | null => { + // Check if .git exists in current directory + const gitDir = join(startDir, '.git'); + if (existsSync(gitDir)) { + return startDir; + } + + // Recursively check parent directories + let currentDir = startDir; + while (currentDir !== dirname(currentDir)) { + const parentGitDir = join(currentDir, '.git'); + if (existsSync(parentGitDir)) { + return currentDir; + } + currentDir = dirname(currentDir); + } + + return null; +}; + +/** + * Validate that a directory is within a git repository + * @param cwd Directory to validate + * @throws Error if not in a git repository + */ +export const validateGitRepository = (cwd: string): void => { + const gitRoot = findGitRepositoryRoot(cwd); + if (!gitRoot) { + throw new Error(`Not a git repository (or any parent directory): ${cwd}`); + } +};