Compare commits
2 Commits
main
...
feat/reduc
| Author | SHA1 | Date | |
|---|---|---|---|
| 997979df54 | |||
| 3e498d032a |
2
common/autoinstallers/rush-commands/.gitignore
vendored
Normal file
2
common/autoinstallers/rush-commands/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
lib
|
||||||
|
dist
|
||||||
@ -1,20 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "rush-commands",
|
"name": "rush-commands",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
|
"description": "Rush command tools and utilities",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {},
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rimraf lib/",
|
||||||
|
"revert-useless-changes": "node lib/revert-useless-changes/cli.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"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"
|
"simple-git": "^3.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
|
"sucrase": "^3.32.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1193
common/autoinstallers/rush-commands/pnpm-lock.yaml
generated
1193
common/autoinstallers/rush-commands/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,5 @@
|
|||||||
|
require('sucrase/register');
|
||||||
|
|
||||||
|
const { main } = require('./revert-useless-changes/cli.ts');
|
||||||
|
|
||||||
|
main();
|
||||||
@ -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<AnalysisReport>`
|
||||||
|
|
||||||
|
**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<RuleResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<RuleResult> => {
|
||||||
|
// 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
|
||||||
@ -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<void> {
|
||||||
|
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 <path>', '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 <patterns...>',
|
||||||
|
'File patterns to include (glob patterns)',
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--exclude <patterns...>',
|
||||||
|
'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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Config } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration values
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CONFIG: Omit<Config, 'cwd'> = {
|
||||||
|
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;
|
||||||
@ -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<import('./types').AnalysisReport> {
|
||||||
|
const { execute } = await import('./orchestrator');
|
||||||
|
return execute(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default export for CLI usage
|
||||||
|
*/
|
||||||
|
export default { analyzeAndRevert };
|
||||||
@ -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<AnalysisReport> => {
|
||||||
|
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<FileAnalysis[]> => {
|
||||||
|
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<FileAnalysis> => {
|
||||||
|
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<AnalysisReport> => {
|
||||||
|
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: []
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
};
|
||||||
@ -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<RuleResult> => {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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';
|
||||||
@ -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, blank lines)',
|
||||||
|
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<RuleResult> => {
|
||||||
|
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, blank lines)'
|
||||||
|
);
|
||||||
|
} 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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<RuleResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for git operations
|
||||||
|
*/
|
||||||
|
export interface GitOptions {
|
||||||
|
/** Working directory for git operations */
|
||||||
|
cwd: string;
|
||||||
|
/** Git reference to compare against (default: HEAD) */
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
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 (including blank lines)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Use -w to ignore whitespace changes and -b to ignore blank line changes
|
||||||
|
// --ignore-space-at-eol ignores changes in whitespace at EOL
|
||||||
|
// --ignore-blank-lines ignores changes whose lines are all blank
|
||||||
|
const output = execSync(`git diff -w -b --ignore-space-at-eol --ignore-blank-lines ${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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user