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/.eslintrc.js +18 -0
- package/.eslintrc.json +25 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +170 -0
- package/dist/cli.js.map +1 -0
- package/dist/file-manager.d.ts +16 -0
- package/dist/file-manager.d.ts.map +1 -0
- package/dist/file-manager.js +135 -0
- package/dist/file-manager.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +222 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner.d.ts +19 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +137 -0
- package/dist/scanner.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +220 -0
- package/dist/src/index.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +16 -0
- package/package.json +50 -0
- package/src/cli.ts +198 -0
- package/src/file-manager.ts +150 -0
- package/src/index.ts +241 -0
- package/src/scanner.ts +159 -0
- package/src/types.ts +23 -0
- package/test/scanner.test.ts +57 -0
- package/tests/index.test.ts +67 -0
- package/tsconfig.json +25 -0
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
|
+
}
|