git-blame-ignore 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scanner.ts ADDED
@@ -0,0 +1,159 @@
1
+ import simpleGit, { SimpleGit } from 'simple-git';
2
+ import { BulkCommit } from './types';
3
+
4
+ export class BulkCommitScanner {
5
+ private git: SimpleGit;
6
+ private config: {
7
+ minFilesChanged: number;
8
+ minBulkScore: number;
9
+ whitespaceThreshold: number;
10
+ };
11
+
12
+ constructor(config?: {
13
+ minFilesChanged?: number;
14
+ minBulkScore?: number;
15
+ whitespaceThreshold?: number;
16
+ }) {
17
+ this.git = simpleGit();
18
+ this.config = {
19
+ minFilesChanged: config?.minFilesChanged || 20,
20
+ minBulkScore: config?.minBulkScore || 40,
21
+ whitespaceThreshold: config?.whitespaceThreshold || 0.8,
22
+ };
23
+ }
24
+
25
+ async scan(): Promise<BulkCommit[]> {
26
+ try {
27
+ const log = await this.git.log({
28
+ format: '%H|%s|%ai|%P',
29
+ file: null,
30
+ n: 100
31
+ });
32
+
33
+ const commits: BulkCommit[] = [];
34
+
35
+ for (const commit of log.all) {
36
+ const [hash] = commit.split('|');
37
+ const bulkCommit = await this.analyzeCommit(hash);
38
+ if (bulkCommit.bulkScore >= this.config.minBulkScore) {
39
+ commits.push(bulkCommit);
40
+ }
41
+ }
42
+
43
+ return commits.sort((a, b) => b.bulkScore - a.bulkScore);
44
+ } catch (error) {
45
+ throw new Error(`Failed to scan commits: ${error}`);
46
+ }
47
+ }
48
+
49
+ private async analyzeCommit(sha: string): Promise<BulkCommit> {
50
+ const diff = await this.git.diff([`${sha}^`, sha, '--name-only']);
51
+ const files = diff.split('\n').filter(f => f.trim() !== '');
52
+
53
+ if (files.length < this.config.minFilesChanged) {
54
+ return this.createEmptyCommit(sha, files.length);
55
+ }
56
+
57
+ const stats = await this.git.diff([`${sha}^`, sha, '--stat']);
58
+ const linesChanged = this.parseLinesChanged(stats);
59
+
60
+ const whitespaceRatio = await this.calculateWhitespaceRatio(sha);
61
+ const message = await this.getCommitMessage(sha);
62
+
63
+ const bulkScore = this.calculateBulkScore(files.length, linesChanged, whitespaceRatio, message);
64
+
65
+ return {
66
+ sha,
67
+ message,
68
+ filesChanged: files.length,
69
+ linesChanged,
70
+ whitespaceRatio,
71
+ bulkScore,
72
+ date: await this.getCommitDate(sha),
73
+ };
74
+ }
75
+
76
+ private createEmptyCommit(sha: string, filesChanged: number): BulkCommit {
77
+ return {
78
+ sha,
79
+ message: 'Unknown',
80
+ filesChanged,
81
+ linesChanged: 0,
82
+ whitespaceRatio: 0,
83
+ bulkScore: 0,
84
+ date: 'Unknown',
85
+ };
86
+ }
87
+
88
+ private parseLinesChanged(stats: string): number {
89
+ const linesMatch = stats.match(/\d+ files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
90
+ if (!linesMatch) return 0;
91
+
92
+ const insertions = linesMatch[1] ? parseInt(linesMatch[1]) : 0;
93
+ const deletions = linesMatch[2] ? parseInt(linesMatch[2]) : 0;
94
+ return insertions + deletions;
95
+ }
96
+
97
+ private async calculateWhitespaceRatio(sha: string): Promise<number> {
98
+ try {
99
+ const diff = await this.git.diff([`${sha}^`, sha, '--']);
100
+ if (!diff) return 0;
101
+
102
+ const lines = diff.split('\n');
103
+ const whitespaceLines = lines.filter(line =>
104
+ line.trim() === '' && line !== 'diff --git' && !line.startsWith('index') && !line.startsWith('---') && !line.startsWith('+++')
105
+ ).length;
106
+
107
+ return lines.length > 0 ? whitespaceLines / lines.length : 0;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ }
112
+
113
+ private async getCommitMessage(sha: string): Promise<string> {
114
+ try {
115
+ const result = await this.git.show([sha, '--format=%s', '--no-patch']);
116
+ return result.trim();
117
+ } catch {
118
+ return 'Unknown';
119
+ }
120
+ }
121
+
122
+ private async getCommitDate(sha: string): Promise<string> {
123
+ try {
124
+ const result = await this.git.show([sha, '--format=%ai', '--no-patch']);
125
+ return result.trim();
126
+ } catch {
127
+ return 'Unknown';
128
+ }
129
+ }
130
+
131
+ private calculateBulkScore(
132
+ filesChanged: number,
133
+ linesChanged: number,
134
+ whitespaceRatio: number,
135
+ message: string
136
+ ): number {
137
+ let score = 0;
138
+
139
+ // Files changed scoring
140
+ if (filesChanged >= 50) score += 50;
141
+ else if (filesChanged >= 30) score += 40;
142
+ else if (filesChanged >= 20) score += 30;
143
+
144
+ // Message scoring
145
+ const lowerMessage = message.toLowerCase();
146
+ if (lowerMessage.includes('prettier') || lowerMessage.includes('format')) score += 20;
147
+ if (lowerMessage.includes('eslint') || lowerMessage.includes('lint')) score += 20;
148
+ if (lowerMessage.includes('rename') || lowerMessage.includes('refactor')) score += 20;
149
+
150
+ // Whitespace scoring
151
+ if (whitespaceRatio >= this.config.whitespaceThreshold) score += 20;
152
+
153
+ // Lines per file scoring (if many changes but few lines per file, likely formatting)
154
+ const linesPerFile = filesChanged > 0 ? linesChanged / filesChanged : 0;
155
+ if (linesPerFile < 5) score += 10;
156
+
157
+ return Math.min(score, 100);
158
+ }
159
+ }
package/src/types.ts ADDED
@@ -0,0 +1,23 @@
1
+ export interface BulkCommit {
2
+ sha: string;
3
+ message: string;
4
+ filesChanged: number;
5
+ linesChanged: number;
6
+ whitespaceRatio: number;
7
+ bulkScore: number;
8
+ date: string;
9
+ }
10
+
11
+ export interface IgnoreEntry {
12
+ sha: string;
13
+ message: string;
14
+ date: string;
15
+ comment?: string;
16
+ }
17
+
18
+ export interface GitBlameIgnoreConfig {
19
+ ignoreFile: string;
20
+ minFilesChanged: number;
21
+ minBulkScore: number;
22
+ whitespaceThreshold: number;
23
+ }
@@ -0,0 +1,57 @@
1
+ import { BulkCommitScanner } from '../src/scanner';
2
+
3
+ describe('BulkCommitScanner', () => {
4
+ let scanner: BulkCommitScanner;
5
+
6
+ beforeEach(() => {
7
+ scanner = new BulkCommitScanner();
8
+ });
9
+
10
+ describe('calculateBulkScore', () => {
11
+ it('should score high for files with many changes and formatting messages', () => {
12
+ const score = (scanner as any).calculateBulkScore(50, 100, 0.9, 'prettier format');
13
+ expect(score).toBeGreaterThan(70);
14
+ });
15
+
16
+ it('should score medium for files with moderate changes', () => {
17
+ const score = (scanner as any).calculateBulkScore(25, 50, 0.5, 'refactor');
18
+ expect(score).toBeGreaterThan(40);
19
+ });
20
+
21
+ it('should score low for files with few changes', () => {
22
+ const score = (scanner as any).calculateBulkScore(10, 20, 0.1, 'bug fix');
23
+ expect(score).toBeLessThan(40);
24
+ });
25
+
26
+ it('should score high for whitespace-only commits', () => {
27
+ const score = (scanner as any).calculateBulkScore(30, 10, 0.95, 'format');
28
+ expect(score).toBeGreaterThan(60);
29
+ });
30
+ });
31
+
32
+ describe('parseLinesChanged', () => {
33
+ it('should parse lines changed from git stat output', () => {
34
+ const stats = ' 3 files changed, 15 insertions(+), 10 deletions(-)';
35
+ const lines = (scanner as any).parseLinesChanged(stats);
36
+ expect(lines).toBe(25);
37
+ });
38
+
39
+ it('should handle output without insertions/deletions', () => {
40
+ const stats = ' 2 files changed';
41
+ const lines = (scanner as any).parseLinesChanged(stats);
42
+ expect(lines).toBe(0);
43
+ });
44
+
45
+ it('should handle output with only insertions', () => {
46
+ const stats = ' 1 file changed, 5 insertions(+)';
47
+ const lines = (scanner as any).parseLinesChanged(stats);
48
+ expect(lines).toBe(5);
49
+ });
50
+
51
+ it('should handle output with only deletions', () => {
52
+ const stats = ' 1 file changed, 3 deletions(-)';
53
+ const lines = (scanner as any).parseLinesChanged(stats);
54
+ expect(lines).toBe(3);
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { execSync } from 'child_process';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+
6
+ const TEST_REPO_DIR = path.join(__dirname, 'test-repo');
7
+ const GIT_BLAME_IGNORE_CMD = 'node dist/index.js';
8
+
9
+ describe('git-blame-ignore', () => {
10
+ beforeEach(async () => {
11
+ // Create a test git repository
12
+ await fs.mkdir(TEST_REPO_DIR, { recursive: true });
13
+ process.chdir(TEST_REPO_DIR);
14
+
15
+ // Initialize git repo
16
+ execSync('git init', { stdio: 'inherit' });
17
+ execSync('git config user.name "Test User"', { stdio: 'inherit' });
18
+ execSync('git config user.email "test@example.com"', { stdio: 'inherit' });
19
+
20
+ // Create some files and commits
21
+ await fs.writeFile('test1.txt', 'content1');
22
+ execSync('git add test1.txt', { stdio: 'inherit' });
23
+ execSync('git commit -m "Initial commit"', { stdio: 'inherit' });
24
+
25
+ await fs.writeFile('test2.txt', 'content2');
26
+ execSync('git add test2.txt', { stdio: 'inherit' });
27
+ execSync('git commit -m "Add test2"', { stdio: 'inherit' });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ // Clean up test repository
32
+ process.chdir(__dirname);
33
+ await fs.rm(TEST_REPO_DIR, { recursive: true, force: true });
34
+ });
35
+
36
+ it('should show help when no command is provided', () => {
37
+ const output = execSync(`node dist/index.js --help`, { encoding: 'utf-8' });
38
+ expect(output).toContain('git-blame-ignore');
39
+ });
40
+
41
+ it('should scan for bulk-change commits', () => {
42
+ // Create a commit with multiple files
43
+ const files = ['file1.txt', 'file2.txt', 'file3.txt'];
44
+ files.forEach(file => {
45
+ execSync(`echo "content" > ${file}`, { stdio: 'inherit' });
46
+ });
47
+ execSync('git add .', { stdio: 'inherit' });
48
+ execSync('git commit -m "Bulk commit with many files"', { stdio: 'inherit' });
49
+
50
+ const output = execSync(`node dist/index.js scan`, { encoding: 'utf-8' });
51
+ expect(output).toContain('Bulk-change commits detected');
52
+ });
53
+
54
+ it('should list ignored commits when file exists', async () => {
55
+ // Create .git-blame-ignore-revs file
56
+ await fs.writeFile('.git-blame-ignore-revs', 'abc123def456\nxyz789uvw456\n');
57
+
58
+ const output = execSync(`node dist/index.js list`, { encoding: 'utf-8' });
59
+ expect(output).toContain('abc123def456');
60
+ expect(output).toContain('xyz789uvw456');
61
+ });
62
+
63
+ it('should handle missing .git-blame-ignore-revs file gracefully', () => {
64
+ const output = execSync(`node dist/index.js list`, { encoding: 'utf-8' });
65
+ expect(output).toContain('No commits are currently ignored');
66
+ });
67
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true,
16
+ "noImplicitAny": true,
17
+ "noImplicitReturns": true,
18
+ "noImplicitThis": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "exactOptionalPropertyTypes": true
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
25
+ }